ab388b4f8c
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1358 lines
62 KiB
TypeScript
1358 lines
62 KiB
TypeScript
/**
|
|
* Single-column sidebar, top to bottom:
|
|
* Project switcher -> new chat (default_agent draft) + fixed nav (Agents / models / cost center /
|
|
* Trace) -> Session area with two grouping modes (a small toggle in the section header; the
|
|
* choice and each Project's group collapse and pin state persist in localStorage): by Workspace
|
|
* (the default; groups loaded Sessions by their
|
|
* Workspace path, auto temp directories merged into one trailing group, header "+" starts a
|
|
* draft in that Workspace) or by Agent (group header = Agent name + new chat + Agent settings;
|
|
* shows all Agents, including empty groups). Groups can be pinned via the header's hover pin
|
|
* toggle: pinned groups sort before unpinned within their mode, keeping each partition's own
|
|
* order -> bottom user config (theme / language / logout).
|
|
* Desktop keeps it pinned as the left column; mobile puts the whole thing in a drawer.
|
|
* New chats always enter draft state (/chat/new, route state specifies the Agent and optionally
|
|
* the Workspace): Model / Workspace / approval mode are all chosen on the draft input card, so
|
|
* there's no longer a separate "quick / advanced" pair of new-chat dialogs.
|
|
* Color scheme is white/gray-based: active state uses a solid gray fill, running status uses a small color dot, no large blocks of color.
|
|
*/
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import type { ReactNode } from "react";
|
|
import { NavLink, useMatch, useNavigate } from "react-router";
|
|
import type {
|
|
SessionCategory,
|
|
SessionCategoryCounts,
|
|
SessionInfo,
|
|
} from "@prismshadow/penguin-server/api";
|
|
import * as api from "../../api/endpoints";
|
|
import { S } from "../../lib/strings";
|
|
import { formatMonthDay } from "../../lib/format";
|
|
import { apiErrorText } from "../../lib/api-error";
|
|
import { useAuth } from "../../state/auth";
|
|
import { useLocale } from "../../state/locale";
|
|
import type { LangPref } from "../../state/locale";
|
|
import { ACCENT_SWATCHES, useTheme } from "../../state/theme";
|
|
import type { Accent, Currency, FontScale, ThemeMode } from "../../state/theme";
|
|
import { agentDisplayName, projectDisplayName, useProject } from "../../state/project";
|
|
import { useSessions } from "../../state/sessions";
|
|
import {
|
|
FOLDER_CATEGORIES,
|
|
SIDEBAR_GROUP_PAGE_SIZE,
|
|
SIDEBAR_PAGE_SIZE,
|
|
aggregateWorkspaceCounts,
|
|
groupSessionsByWorkspace,
|
|
partitionSessions,
|
|
pinnedFirst,
|
|
sessionCategory,
|
|
workspaceGroupKey,
|
|
} from "../../lib/session-grouping";
|
|
import type { FolderCategory, SessionPartition } from "../../lib/session-grouping";
|
|
import { Switch } from "../ui/switch";
|
|
import { Dropdown } from "../ui/dropdown";
|
|
import { AgentAvatar } from "../ui/agent-avatar";
|
|
import { ChevronDown, NAV_ICONS } from "../ui/icons";
|
|
import {
|
|
FOLDER_ICON,
|
|
FOLDER_OPEN_ICON,
|
|
FolderSection,
|
|
GroupHeader,
|
|
GroupModeToggle,
|
|
Icon,
|
|
MoreRow,
|
|
initialGroupMode,
|
|
storeGroupMode,
|
|
} from "../ui/group-list";
|
|
import type { GroupMode } from "../ui/group-list";
|
|
import { toastError, toastInfo, toastSuccess } from "../ui/toast";
|
|
import { Truncated } from "../ui/truncated";
|
|
import { Badge } from "../ui/badge";
|
|
import { Modal } from "../ui/modal";
|
|
import { ConfirmModal } from "../ui/confirm-modal";
|
|
import { Button } from "../ui/button";
|
|
import { Input } from "../ui/input";
|
|
import { Segmented } from "../ui/segmented";
|
|
import { SkeletonList } from "../ui/skeleton";
|
|
import { DRAFT_SESSION_ID } from "../../features/chat/chat-page";
|
|
import { clearDraft, sessionDraftKey } from "../../features/chat/draft-cache";
|
|
import { CreateProjectDialog, ProjectSettingsDialog } from "./project-dialogs";
|
|
import { ChangePasswordDialog } from "../account/change-password-dialog";
|
|
import { UpdateDialog } from "../account/update-dialog";
|
|
import { forceUpdateCheck, updateCheckOutcome, useVersionInfo } from "../../lib/use-version-info";
|
|
|
|
/** New-chat pencil (the pinned "New chat" button and the collapsed rail share it). */
|
|
export const NEW_CHAT_ICON = "M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z";
|
|
|
|
/** Standard gear (lucide settings): full tooth outline + center circle, crisp and undistorted at 16px. */
|
|
const GEAR_ICON =
|
|
"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z";
|
|
|
|
/** Pushpin (lucide pin: head + body + stem), the group-header pin toggle / pinned indicator. */
|
|
const PIN_ICON =
|
|
"M12 17v5M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1z";
|
|
|
|
const menuItemClass =
|
|
"block w-full px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800";
|
|
|
|
/**
|
|
* Collapsed-group and pinned-group persistence (survives a refresh), one storage key
|
|
* per Project and concern — group keys are Agent ids / Workspace paths, which are
|
|
* Project-scoped. Both grouping modes share one set per concern (their key spaces
|
|
* never collide); stray keys left by deleted Agents or Workspaces are harmless
|
|
* (never matched) and the per-Project sets stay tiny.
|
|
*/
|
|
const collapsedGroupsKey = (projectId: string) => `penguin.sidebarCollapsedGroups.${projectId}`;
|
|
const pinnedGroupsKey = (projectId: string) => `penguin.sidebarPinnedGroups.${projectId}`;
|
|
/** Reads a persisted group-key set (no Project yet / corrupted storage degrade to empty). */
|
|
function loadGroupSet(storageKey: string | null): ReadonlySet<string> {
|
|
if (!storageKey) return new Set();
|
|
try {
|
|
const parsed: unknown = JSON.parse(localStorage.getItem(storageKey) ?? "[]");
|
|
return new Set(
|
|
Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : [],
|
|
);
|
|
} catch {
|
|
return new Set();
|
|
}
|
|
}
|
|
function saveGroupSet(storageKey: string | null, next: ReadonlySet<string>): void {
|
|
if (!storageKey) return;
|
|
try {
|
|
localStorage.setItem(storageKey, JSON.stringify([...next]));
|
|
} catch {
|
|
/* best-effort persistence (quota/private mode) */
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Open-state key of a collapsed folder (subagent / scheduled / archived) inside a group:
|
|
* each folder has its own state. "\0" never appears in Agent ids or Workspace paths, so
|
|
* the composite never collides across groups or with plain group keys.
|
|
*/
|
|
const folderKey = (groupKey: string, category: FolderCategory) => `${category}\0${groupKey}`;
|
|
|
|
/** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */
|
|
function StatusDot({ session }: { session: SessionInfo }) {
|
|
if (session.status === "running") {
|
|
return (
|
|
<span
|
|
title={S.chat.statusRunning}
|
|
className="h-1.5 w-1.5 shrink-0 animate-pulse rounded-full bg-emerald-500"
|
|
/>
|
|
);
|
|
}
|
|
if (session.status === "compacting") {
|
|
return (
|
|
<span
|
|
title={S.chat.statusCompacting}
|
|
className="h-1.5 w-1.5 shrink-0 rounded-full bg-amber-500"
|
|
/>
|
|
);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function Sidebar({
|
|
onNavigate,
|
|
onCollapse,
|
|
}: {
|
|
onNavigate?: () => void;
|
|
onCollapse?: () => void;
|
|
}) {
|
|
const navigate = useNavigate();
|
|
const { user, logout, desktopMode } = useAuth();
|
|
const { mode, setMode, fontScale, setFontScale, accent, setAccent, currency, setCurrency } =
|
|
useTheme();
|
|
const { lang, locale, setLang } = useLocale();
|
|
const {
|
|
projects,
|
|
currentProject,
|
|
setCurrentProjectId,
|
|
reloadProjects,
|
|
agents,
|
|
currentAgent,
|
|
setCurrentAgentId,
|
|
} = useProject();
|
|
const {
|
|
sessions,
|
|
byAgent,
|
|
countsByAgent,
|
|
workspaceCountsByAgent,
|
|
isLoadedFor,
|
|
hasMoreFor,
|
|
loadMoreFor,
|
|
loading,
|
|
remove,
|
|
replace,
|
|
showCliSessions,
|
|
setShowCliSessions,
|
|
} = useSessions();
|
|
const chatMatch = useMatch("/chat/:sessionId");
|
|
const activeSessionId = chatMatch?.params.sessionId ?? null;
|
|
|
|
const [projectOpen, setProjectOpen] = useState(false);
|
|
const [userOpen, setUserOpen] = useState(false);
|
|
const [createProjectOpen, setCreateProjectOpen] = useState(false);
|
|
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
|
|
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
|
const [updateDialogOpen, setUpdateDialogOpen] = useState(false);
|
|
// Version row + update reminder: nothing is fetched until the dropdown first opens.
|
|
const { version, update } = useVersionInfo(userOpen);
|
|
const updateAvailable = update?.updateAvailable === true;
|
|
/**
|
|
* The newer release's version string, or null while none is known — the single update row's
|
|
* whole state machine. A resolved version is required, not just the boolean: the row's label
|
|
* names it, so a would-be "available but unnamed" result stays on the check action rather
|
|
* than rendering a versionless reminder.
|
|
*/
|
|
const newVersion = updateAvailable ? (update?.latestVersion ?? null) : null;
|
|
// The running version's release date, stamped into core's BUILD_DATE at build time by
|
|
// the release workflow — displayed as-is, no network involved. Dev builds and releases
|
|
// that predate the stamping (v0.1.2 and earlier) carry null. Shown as the localized
|
|
// "last updated" tooltip on the check-for-updates row (the row itself stays uncluttered).
|
|
const versionDate = version?.buildDate ?? null;
|
|
/** Manual "check for updates" in flight (row disabled, busy label). */
|
|
const [updateChecking, setUpdateChecking] = useState(false);
|
|
/**
|
|
* Manual update check (owner request): forces a lookup past the server's TTL cache and
|
|
* pushes the result into the shared version-info store, so the reminder rows, badge,
|
|
* and dot appear immediately when a newer release is found. Every outcome also toasts —
|
|
* up to date, found (naming the release; the row below turns into the update entry),
|
|
* checks disabled, and a failed lookup (the check is fail-soft — failure arrives as the
|
|
* `error` field, not an exception; the catch handles our own server being unreachable).
|
|
*/
|
|
const runUpdateCheck = async () => {
|
|
if (updateChecking) return;
|
|
setUpdateChecking(true);
|
|
try {
|
|
const outcome = updateCheckOutcome(await forceUpdateCheck());
|
|
if (outcome.kind === "disabled") toastInfo(S.update.checkDisabled);
|
|
else if (outcome.kind === "failed") toastError(S.update.checkFailed);
|
|
else if (outcome.kind === "found") toastSuccess(S.update.foundNew(outcome.latestVersion));
|
|
else toastSuccess(S.update.upToDate);
|
|
} catch (e) {
|
|
toastError(apiErrorText(e));
|
|
} finally {
|
|
setUpdateChecking(false);
|
|
}
|
|
};
|
|
const currentProjectId = currentProject?.projectId ?? null;
|
|
const collapseStoreKey = currentProjectId === null ? null : collapsedGroupsKey(currentProjectId);
|
|
const pinStoreKey = currentProjectId === null ? null : pinnedGroupsKey(currentProjectId);
|
|
/** Grouping mode of the Session list (Workspace by default; the choice persists across sessions). */
|
|
const [groupMode, setGroupModeState] = useState<GroupMode>(initialGroupMode);
|
|
/** Collapsed groups (expanded by default), keyed by Agent id or Workspace group key depending on the mode; persisted per Project. */
|
|
const [collapsedGroups, setCollapsedGroups] = useState<ReadonlySet<string>>(() =>
|
|
loadGroupSet(collapseStoreKey),
|
|
);
|
|
/** Pinned groups (sorted before unpinned within their mode), keyed like collapsedGroups; persisted per Project. */
|
|
const [pinnedGroups, setPinnedGroups] = useState<ReadonlySet<string>>(() =>
|
|
loadGroupSet(pinStoreKey),
|
|
);
|
|
// Project resolved on first load / switched: swap in that Project's persisted collapse/pin sets.
|
|
useEffect(() => {
|
|
setCollapsedGroups(loadGroupSet(collapseStoreKey));
|
|
setPinnedGroups(loadGroupSet(pinStoreKey));
|
|
setGroupCap(SIDEBAR_GROUP_PAGE_SIZE);
|
|
}, [collapseStoreKey, pinStoreKey]);
|
|
/** Expanded folders (subagent / scheduled / archived; collapsed by default), keyed by folderKey — each folder has its own open state. */
|
|
const [openFolders, setOpenFolders] = useState<ReadonlySet<string>>(new Set());
|
|
/** "More" rows with a fetch in flight, keyed `${category}\0${groupKey}` — the row disables and reads "loading" so a page that lands entirely in other groups still visibly did something. */
|
|
const [pendingLoads, setPendingLoads] = useState<ReadonlySet<string>>(new Set());
|
|
/** Per-group display cap for active rows (keyed by group key; absent = SIDEBAR_PAGE_SIZE). "More" raises it a page at a time. */
|
|
const [groupCaps, setGroupCaps] = useState<ReadonlyMap<string, number>>(new Map());
|
|
/** How many GROUPS render (#139: dozens of Agents/Workspaces made the list too tall to scan); "more groups" raises it a page at a time, reset per Project and on a mode switch. */
|
|
const [groupCap, setGroupCap] = useState(SIDEBAR_GROUP_PAGE_SIZE);
|
|
/** Session pending delete confirmation (null = none). */
|
|
const [deletingSession, setDeletingSession] = useState<SessionInfo | null>(null);
|
|
const [deletingBusy, setDeletingBusy] = useState(false);
|
|
/** Session currently being renamed (null = none) and the title being typed. */
|
|
const [renamingSession, setRenamingSession] = useState<SessionInfo | null>(null);
|
|
const [renameText, setRenameText] = useState("");
|
|
const [renameBusy, setRenameBusy] = useState(false);
|
|
const [renameError, setRenameError] = useState<string | null>(null);
|
|
|
|
const setGroupMode = (mode: GroupMode) => {
|
|
storeGroupMode(mode);
|
|
setGroupModeState(mode);
|
|
// The two modes have unrelated group lists: restart the reveal window.
|
|
setGroupCap(SIDEBAR_GROUP_PAGE_SIZE);
|
|
};
|
|
|
|
/** Workspace groups (workspace mode): computed from the flat list, temp directories merged last. */
|
|
const workspaceGroups = useMemo(() => groupSessionsByWorkspace(sessions), [sessions]);
|
|
|
|
/** Workspace-mode per-group exact server totals (folded from the per-Agent per-Workspace counts). */
|
|
const workspaceGroupCounts = useMemo(
|
|
() => aggregateWorkspaceCounts(workspaceCountsByAgent),
|
|
[workspaceCountsByAgent],
|
|
);
|
|
|
|
// Pinned groups first within each mode; inside each partition the existing order is kept
|
|
// (recency for Workspace groups, the configured Agent order for Agents).
|
|
const orderedAgents = useMemo(
|
|
() => pinnedFirst(agents, (a) => a.agentId, pinnedGroups),
|
|
[agents, pinnedGroups],
|
|
);
|
|
const orderedWorkspaceGroups = useMemo(
|
|
() => pinnedFirst(workspaceGroups, (g) => g.key, pinnedGroups),
|
|
[workspaceGroups, pinnedGroups],
|
|
);
|
|
|
|
/** Group key of a Session under the current mode (collapse / archived-open state). */
|
|
const sessionGroupKey = (s: SessionInfo) =>
|
|
groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace);
|
|
|
|
const toggleGroup = (key: string) => {
|
|
// Computed outside the state updater (theme.tsx convention): the persistence write is a
|
|
// side effect, and updaters must stay pure (double-invoked in StrictMode).
|
|
const next = new Set(collapsedGroups);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
setCollapsedGroups(next);
|
|
saveGroupSet(collapseStoreKey, next);
|
|
};
|
|
|
|
/** Pin / unpin a group (same toggle-and-persist convention as toggleGroup). */
|
|
const togglePin = (key: string) => {
|
|
const next = new Set(pinnedGroups);
|
|
if (next.has(key)) next.delete(key);
|
|
else next.add(key);
|
|
setPinnedGroups(next);
|
|
saveGroupSet(pinStoreKey, next);
|
|
};
|
|
|
|
/** In-flight key of one group's category "More" (folderKey shares the same composite for folder categories). */
|
|
const loadKey = (groupKey: string, category: SessionCategory) => `${category}\0${groupKey}`;
|
|
|
|
/** loadMoreFor with an in-flight marker for the triggering "More" row (disable + loading text). */
|
|
const trackedLoadMore = (groupKey: string, category: SessionCategory, agentIds: string[]) => {
|
|
const key = loadKey(groupKey, category);
|
|
setPendingLoads((prev) => new Set(prev).add(key));
|
|
void loadMoreFor(agentIds, category).finally(() => {
|
|
setPendingLoads((prev) => {
|
|
const next = new Set(prev);
|
|
next.delete(key);
|
|
return next;
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Open/close a group's folder. A folder's content is loaded on demand: the first
|
|
* expand fetches its category's first page for every contributing Agent that hasn't
|
|
* been asked yet (already-loaded rows stay put — re-expanding never refetches; the
|
|
* folder's own "More" row does the paging from there).
|
|
*/
|
|
const toggleFolder = (groupKey: string, category: FolderCategory, agentIds: string[]) => {
|
|
const key = folderKey(groupKey, category);
|
|
const opening = !openFolders.has(key);
|
|
setOpenFolders((prev) => {
|
|
const next = new Set(prev);
|
|
if (opening) next.add(key);
|
|
else next.delete(key);
|
|
return next;
|
|
});
|
|
if (opening) {
|
|
const unloaded = agentIds.filter((id) => !isLoadedFor(id, category));
|
|
if (unloaded.length > 0) void loadMoreFor(unloaded, category);
|
|
}
|
|
};
|
|
|
|
// The open chat is an automation-created Session: expand exactly its origin's folder in its
|
|
// group, so the active row is never hidden inside a collapsed folder (mirrors the archived
|
|
// expansion on archiving the open chat; archived wins, so an archived Session is left to
|
|
// that folder). Auto-expansion fires ONCE per (grouping mode, active session): the ref guard
|
|
// keeps list mutations (status ticks, reloads) from re-opening a folder the user explicitly
|
|
// collapsed while that chat stays open. `sessions` must remain a dependency — the active
|
|
// session may not be in the list yet on first render, and the guard is only set once the
|
|
// row is actually found and expanded.
|
|
const lastAutoExpandedRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
if (!activeSessionId) return;
|
|
const s = sessions.find((x) => x.sessionId === activeSessionId);
|
|
if (!s) return;
|
|
const category = sessionCategory(s);
|
|
if (category === "active" || category === "archived") return;
|
|
const guard = `${groupMode}\0${activeSessionId}`;
|
|
if (lastAutoExpandedRef.current === guard) return;
|
|
lastAutoExpandedRef.current = guard;
|
|
const groupKey = groupMode === "agent" ? s.agentId : workspaceGroupKey(s.workspace);
|
|
const key = folderKey(groupKey, category);
|
|
setOpenFolders((prev) => (prev.has(key) ? prev : new Set(prev).add(key)));
|
|
// Same on-demand load a click-expand does, for this Session's own Agent (siblings of
|
|
// other contributing Agents stay behind the folder's "More").
|
|
if (!isLoadedFor(s.agentId, category)) void loadMoreFor([s.agentId], category);
|
|
}, [activeSessionId, sessions, groupMode, isLoadedFor, loadMoreFor]);
|
|
|
|
/** Archive / unarchive: persists immediately and updates in place (fails silently; the next list refresh self-corrects). */
|
|
const toggleArchive = async (s: SessionInfo) => {
|
|
// Archiving the currently open chat: expand the "archived" folder so it doesn't silently vanish from the sidebar with no way back.
|
|
if (!s.archived && s.sessionId === activeSessionId) {
|
|
setOpenFolders((prev) => new Set(prev).add(folderKey(sessionGroupKey(s), "archived")));
|
|
}
|
|
try {
|
|
const res = await api.patchSession(s.sessionId, { archived: !s.archived });
|
|
replace(res.session);
|
|
} catch {
|
|
/* Ignore: non-critical operation */
|
|
}
|
|
};
|
|
|
|
const confirmRename = async () => {
|
|
if (!renamingSession) return;
|
|
const title = renameText.trim();
|
|
if (!title) return;
|
|
setRenameBusy(true);
|
|
setRenameError(null);
|
|
try {
|
|
const res = await api.patchSession(renamingSession.sessionId, { title });
|
|
replace(res.session);
|
|
setRenamingSession(null);
|
|
} catch (e) {
|
|
setRenameError(apiErrorText(e));
|
|
} finally {
|
|
setRenameBusy(false);
|
|
}
|
|
};
|
|
|
|
const confirmDeleteSession = async () => {
|
|
if (!deletingSession) return;
|
|
setDeletingBusy(true);
|
|
const target = deletingSession;
|
|
try {
|
|
await api.deleteSession(target.sessionId);
|
|
remove(target.sessionId);
|
|
// The session is gone, so clear its input draft too (no orphaned keys left in localStorage; keys are scoped per user, #68).
|
|
if (user) clearDraft(sessionDraftKey(user.userId, target.sessionId));
|
|
setDeletingSession(null);
|
|
// The deleted session was the one open: jump to this Agent's next conversation, otherwise
|
|
// fall back to the chat home page. Auto-opened conversations are never archived (hidden by
|
|
// default — landing there would look like the chat vanished into thin air) and never
|
|
// subagent children (they belong to some other conversation).
|
|
if (activeSessionId === target.sessionId) {
|
|
const rest = (byAgent.get(target.agentId) ?? []).filter((s) => {
|
|
const category = sessionCategory(s);
|
|
return (
|
|
s.sessionId !== target.sessionId && (category === "active" || category === "schedule")
|
|
);
|
|
});
|
|
navigate(rest[0] ? `/chat/${rest[0].sessionId}` : "/chat");
|
|
}
|
|
} catch (e) {
|
|
toastError(apiErrorText(e));
|
|
} finally {
|
|
setDeletingBusy(false);
|
|
}
|
|
};
|
|
|
|
const go = (to: string) => {
|
|
navigate(to);
|
|
onNavigate?.();
|
|
};
|
|
|
|
/**
|
|
* New chat: enters draft state (/chat/new) without creating a Session — Model / Workspace /
|
|
* approval mode are all chosen on the draft input card, and the Session is only actually
|
|
* created when the first message is sent. The route state explicitly carries the target
|
|
* Agent: the agent-mode group header's "+" uses that group's Agent, while the menu's "New
|
|
* chat" uses default_agent; this explicit intent overrides the previously selected Agent in
|
|
* the draft cache (the rest of the draft content, such as the message body, is preserved).
|
|
* The workspace-mode group header's "+" additionally carries that group's Workspace path
|
|
* ("" = the auto temp directory), pre-filling the draft's Workspace selection the same way.
|
|
*/
|
|
const newChat = (agentId?: string, workspace?: string) => {
|
|
if (agentId) setCurrentAgentId(agentId);
|
|
const state = {
|
|
...(agentId ? { agentId } : {}),
|
|
...(workspace !== undefined ? { workspace } : {}),
|
|
};
|
|
navigate(`/chat/${DRAFT_SESSION_ID}`, Object.keys(state).length > 0 ? { state } : undefined);
|
|
onNavigate?.();
|
|
};
|
|
|
|
/** Target of the menu's "New chat": default_agent, falling back to the first Agent (if the list isn't ready yet, resolution is deferred to the draft page). */
|
|
const defaultAgentId = (agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId;
|
|
|
|
/** A Session always needs an Agent, so the workspace-mode "+" uses the current Agent, falling back to default_agent. */
|
|
const workspaceNewChatAgentId = currentAgent?.agentId ?? defaultAgentId;
|
|
|
|
const openSession = (s: SessionInfo) => {
|
|
// Cross-group click: the current Agent follows this Session's own Agent.
|
|
setCurrentAgentId(s.agentId);
|
|
go(`/chat/${s.sessionId}`);
|
|
};
|
|
|
|
/** agentId → display name (row hint tooltips in workspace mode). */
|
|
const agentNameById = useMemo(
|
|
() => new Map(agents.map((a) => [a.agentId, agentDisplayName(a)])),
|
|
[agents],
|
|
);
|
|
|
|
/** Session rows shared by both modes; withAgentHint adds a small Agent avatar per row (workspace mode, where the group no longer names the Agent). */
|
|
const renderRows = (rows: SessionInfo[], withAgentHint: boolean) => (
|
|
<ul className="space-y-0.5">
|
|
{rows.map((s) => (
|
|
<SessionRow
|
|
key={s.sessionId}
|
|
s={s}
|
|
active={s.sessionId === activeSessionId}
|
|
{...(withAgentHint ? { agentHint: agentNameById.get(s.agentId) ?? s.agentId } : {})}
|
|
onOpen={openSession}
|
|
onRename={(x) => {
|
|
setRenameError(null);
|
|
setRenameText(x.title ?? "");
|
|
setRenamingSession(x);
|
|
}}
|
|
onDelete={(x) => setDeletingSession(x)}
|
|
onToggleArchive={(x) => void toggleArchive(x)}
|
|
/>
|
|
))}
|
|
</ul>
|
|
);
|
|
|
|
/**
|
|
* Collapsed-by-default lazy folder (subagent / scheduled / archived): nothing is
|
|
* fetched until the first expand, and once open the folder pages independently with
|
|
* its own "More" row. Everything is driven by the group's **own** exact server share
|
|
* (`totals` — the Agent's counts in agent mode, the per-Workspace fold in workspace
|
|
* mode): the folder exists only while its share is non-zero, the label shows that
|
|
* share, and "More" shows only while loaded rows fall short of it — an Agent's
|
|
* content in *other* Workspaces can never surface a folder here. The folder's "More"
|
|
* pages independently of the active list's; in workspace mode a fetched page can land
|
|
* rows in other groups' folders too, so one click may grow this folder by fewer than
|
|
* a full page — the row shows a loading state while the fetch runs and stays until
|
|
* this group's share is fully loaded.
|
|
*/
|
|
const renderFolder = (
|
|
groupKey: string,
|
|
category: FolderCategory,
|
|
parts: SessionPartition,
|
|
withAgentHint: boolean,
|
|
/** Agents that may hold this group's rows of this category (fetch fan-out set). */
|
|
agentIds: string[],
|
|
totals: SessionCategoryCounts | undefined,
|
|
) => {
|
|
const rows = parts[category];
|
|
// Loaded rows win a disagreement with the totals (counts refresh only on reload).
|
|
const total = Math.max(totals?.[category] ?? 0, rows.length);
|
|
if (total === 0) return null;
|
|
// More while the group's share isn't fully loaded AND somewhere is left to fetch from
|
|
// (counts drifting above reality would otherwise leave a dead button until reload).
|
|
const more = rows.length < total && agentIds.some((id) => hasMoreFor(id, category));
|
|
return (
|
|
<FolderSection
|
|
key={category}
|
|
label={S.chat.folderGroups[category](total)}
|
|
open={openFolders.has(folderKey(groupKey, category))}
|
|
onToggle={() => toggleFolder(groupKey, category, agentIds)}
|
|
more={more}
|
|
pending={pendingLoads.has(loadKey(groupKey, category))}
|
|
onMore={() => trackedLoadMore(groupKey, category, agentIds)}
|
|
>
|
|
{renderRows(rows, withAgentHint)}
|
|
</FolderSection>
|
|
);
|
|
};
|
|
|
|
/** Active-list "More": reveal one more page of already-loaded active rows AND fetch the next active server page for every Agent that still has one. */
|
|
const showMore = (groupKey: string, agentIds: string[]) => {
|
|
setGroupCaps((prev) => {
|
|
const next = new Map(prev);
|
|
next.set(groupKey, (prev.get(groupKey) ?? SIDEBAR_PAGE_SIZE) + SIDEBAR_PAGE_SIZE);
|
|
return next;
|
|
});
|
|
if (agentIds.length > 0) trackedLoadMore(groupKey, "active", agentIds);
|
|
};
|
|
|
|
/**
|
|
* Expanded group body shared by both modes: active user rows (display-capped; "More"
|
|
* reveals and loads further **active-only** pages — the folders below never feed it) +
|
|
* the collapsed-by-default subagent / scheduled / archived folders, each loading on
|
|
* first expand and paging on its own. `totals` / `agentsFor` carry the group's exact
|
|
* server share and its fetch fan-out set per category.
|
|
*/
|
|
const renderGroupBody = (
|
|
groupKey: string,
|
|
parts: SessionPartition,
|
|
withAgentHint: boolean,
|
|
totals: SessionCategoryCounts | undefined,
|
|
agentsFor: (category: SessionCategory) => string[],
|
|
) => {
|
|
const cap = groupCaps.get(groupKey) ?? SIDEBAR_PAGE_SIZE;
|
|
const shownActive = parts.active.slice(0, cap);
|
|
// Only the outer active rows drive the group's "More" — the folders never feed it:
|
|
// hidden loaded rows exist, or the group's own active share isn't fully loaded yet.
|
|
const activeAgents = agentsFor("active");
|
|
const activeTotal = Math.max(totals?.active ?? 0, parts.active.length);
|
|
const hasMore =
|
|
parts.active.length > cap ||
|
|
(parts.active.length < activeTotal && activeAgents.some((id) => hasMoreFor(id, "active")));
|
|
const folders = FOLDER_CATEGORIES.map((category) =>
|
|
renderFolder(groupKey, category, parts, withAgentHint, agentsFor(category), totals),
|
|
);
|
|
const empty = parts.active.length === 0 && folders.every((f) => f === null);
|
|
const activePending = pendingLoads.has(loadKey(groupKey, "active"));
|
|
return (
|
|
<>
|
|
{empty ? (
|
|
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">
|
|
{S.chat.noSessions}
|
|
</p>
|
|
) : (
|
|
renderRows(shownActive, withAgentHint)
|
|
)}
|
|
|
|
{/* Load/reveal more (kept adjacent to the active list it extends, above the folders) */}
|
|
{hasMore && (
|
|
<MoreRow
|
|
label={S.chat.loadMore}
|
|
pending={activePending}
|
|
onClick={() => showMore(groupKey, activeAgents)}
|
|
className="mt-0.5"
|
|
/>
|
|
)}
|
|
|
|
{/* Folders (collapsed by default): subagent first — spawned from the conversations
|
|
at hand — then scheduled background runs, then archived (archived wins over the
|
|
origin folders). */}
|
|
{folders}
|
|
</>
|
|
);
|
|
};
|
|
|
|
/** Reveal-next-page-of-groups row (render cap only — data loading is untouched). */
|
|
const moreGroupsRow = (total: number) => (
|
|
<MoreRow
|
|
label={S.chat.moreGroups(total - groupCap)}
|
|
onClick={() => setGroupCap((c) => c + SIDEBAR_GROUP_PAGE_SIZE)}
|
|
className="mt-1"
|
|
/>
|
|
);
|
|
|
|
const navItems: Array<{ to: string; label: string; icon: string }> = [
|
|
{ to: "/agents", label: S.nav.agents, icon: NAV_ICONS.agents },
|
|
{ to: "/skills", label: S.nav.skills, icon: NAV_ICONS.skills },
|
|
{ to: "/models", label: S.nav.models, icon: NAV_ICONS.models },
|
|
{ to: "/usage", label: S.nav.usage, icon: NAV_ICONS.usage },
|
|
{ to: "/traces", label: S.nav.traces, icon: NAV_ICONS.traces },
|
|
{ to: "/benchmark", label: S.nav.benchmark, icon: NAV_ICONS.benchmark },
|
|
];
|
|
|
|
const themeOptions: ReadonlyArray<{ value: ThemeMode; label: string }> = [
|
|
{ value: "light", label: S.settings.themeLight },
|
|
{ value: "dark", label: S.settings.themeDark },
|
|
{ value: "system", label: S.settings.followSystem },
|
|
];
|
|
const langOptions: ReadonlyArray<{ value: LangPref; label: string }> = [
|
|
{ value: "en", label: S.settings.langEn },
|
|
{ value: "zh", label: S.settings.langZh },
|
|
{ value: "system", label: S.settings.followSystem },
|
|
];
|
|
const fontOptions: ReadonlyArray<{ value: FontScale; label: string }> = [
|
|
{ value: "sm", label: S.settings.fontSmall },
|
|
{ value: "md", label: S.settings.fontMedium },
|
|
{ value: "lg", label: S.settings.fontLarge },
|
|
];
|
|
const currencyOptions: ReadonlyArray<{ value: Currency; label: string }> = [
|
|
{ value: "USD", label: S.models.currencyUsd },
|
|
{ value: "CNY", label: S.models.currencyCny },
|
|
];
|
|
|
|
return (
|
|
<div className="flex h-full w-full flex-col">
|
|
{/* Project switcher (+ collapse sidebar) */}
|
|
<div className="flex shrink-0 items-center gap-1 px-2 pt-2">
|
|
{onCollapse && (
|
|
<button
|
|
type="button"
|
|
title={S.nav.collapseSidebar}
|
|
aria-label={S.nav.collapseSidebar}
|
|
onClick={onCollapse}
|
|
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
|
>
|
|
<Icon d="M15 6l-6 6 6 6M4 4v16" size={18} />
|
|
</button>
|
|
)}
|
|
<Dropdown
|
|
open={projectOpen}
|
|
setOpen={setProjectOpen}
|
|
className="min-w-0 flex-1"
|
|
menuClass="left-0 right-0 top-full mt-1 origin-top"
|
|
button={
|
|
<button
|
|
type="button"
|
|
onClick={() => setProjectOpen(!projectOpen)}
|
|
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-base font-semibold transition-colors duration-150 hover:bg-gray-200/70 dark:hover:bg-gray-800"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate text-left">
|
|
{currentProject ? projectDisplayName(currentProject) : S.common.loading}
|
|
</span>
|
|
<span className="text-gray-400">
|
|
<ChevronDown />
|
|
</span>
|
|
</button>
|
|
}
|
|
>
|
|
{projects.map((p) => (
|
|
<button
|
|
key={p.projectId}
|
|
type="button"
|
|
onClick={() => {
|
|
setCurrentProjectId(p.projectId);
|
|
setProjectOpen(false);
|
|
}}
|
|
className={`flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm transition-colors duration-150 hover:bg-gray-100 dark:hover:bg-gray-800 ${
|
|
p.projectId === currentProject?.projectId ? "font-semibold" : ""
|
|
}`}
|
|
>
|
|
<span className="truncate">{projectDisplayName(p)}</span>
|
|
<Badge tone="gray">{p.role}</Badge>
|
|
</button>
|
|
))}
|
|
<div className="mt-1.5 border-t border-gray-100 pt-1.5 dark:border-gray-800">
|
|
<button
|
|
type="button"
|
|
className={menuItemClass}
|
|
onClick={() => {
|
|
setProjectOpen(false);
|
|
setCreateProjectOpen(true);
|
|
}}
|
|
>
|
|
+ {S.project.create}
|
|
</button>
|
|
{currentProject && (
|
|
<button
|
|
type="button"
|
|
className={menuItemClass}
|
|
onClick={() => {
|
|
setProjectOpen(false);
|
|
setProjectSettingsOpen(true);
|
|
}}
|
|
>
|
|
{S.project.settings}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Dropdown>
|
|
</div>
|
|
|
|
{/* New chat: the only pinned entry besides the Project switcher above and the user row
|
|
below. No background fill, the same gray hover/active styling as the nav items,
|
|
distinguished only by its position and font-medium; shows the same gray active state
|
|
while on the draft page.
|
|
The gap to the scroll area below is this block's OWN pb-2, not padding inside the
|
|
scroller: padding-top there belongs to the scrollable content and slides away with
|
|
it, so a scrolled nav entry ended up flush against this pinned button, the two
|
|
labels touching. Outside the scroller the 8px stays put at every scroll offset —
|
|
the same text-to-text rhythm two adjacent nav rows have. */}
|
|
<div className="shrink-0 px-2 pb-2 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => newChat(defaultAgentId)}
|
|
className={`flex w-full items-center gap-2 rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors duration-150 ${
|
|
activeSessionId === DRAFT_SESSION_ID
|
|
? "bg-gray-200/70 text-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
|
: "text-gray-600 hover:bg-gray-200/50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/70 dark:hover:text-gray-200"
|
|
}`}
|
|
>
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
<Icon d={NEW_CHAT_ICON} />
|
|
</span>
|
|
{S.chat.newSessionMenu}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Scroll area: the page nav and the session list scroll together, so the nav rides up
|
|
as the list is scrolled. It is the sidebar's only shrinkable block — with the nav
|
|
pinned, the column's fixed height (Project switcher + New chat + eight nav entries +
|
|
user row ≈ 412px) exceeded a short window, and the overflow, clipped by nothing,
|
|
grew the document into a second scrollbar.
|
|
relative: the scroller acts as its own containing block, so absolute descendants
|
|
(each row's sr-only Agent name) anchor and scroll inside it — anchored to the
|
|
initial containing block instead, rows past the fold would bypass this
|
|
overflow-y-auto and stretch the **document**, so expanding "More" / a source
|
|
folder made the whole page scroll (composer pushed up, blank space below). */}
|
|
<div className="relative min-h-0 flex-1 overflow-y-auto px-2 pb-2">
|
|
<nav className="space-y-0.5">
|
|
{navItems.map((item) => (
|
|
<NavLink
|
|
key={item.to}
|
|
to={item.to}
|
|
onClick={() => onNavigate?.()}
|
|
className={({ isActive }) =>
|
|
`flex items-center gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors duration-150 ${
|
|
isActive
|
|
? "bg-gray-200/70 font-medium text-gray-900 dark:bg-gray-800 dark:text-gray-100"
|
|
: "text-gray-600 hover:bg-gray-200/50 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800/70 dark:hover:text-gray-200"
|
|
}`
|
|
}
|
|
>
|
|
<span className="text-gray-500 dark:text-gray-400">
|
|
<Icon d={item.icon} />
|
|
</span>
|
|
{item.label}
|
|
</NavLink>
|
|
))}
|
|
</nav>
|
|
|
|
{/* Section header: list label + grouping-mode toggle (the choice persists in localStorage).
|
|
The separator above it spans the sidebar's full width (-mx-2 undoes the scroller's
|
|
padding, px-3 puts the row's own inset back), as it did when it sat on the scroller's
|
|
top edge — it now travels with the list instead of framing a pinned nav. */}
|
|
<div className="-mx-2 mt-3 flex items-center justify-between border-t border-gray-200 px-3 pt-2 dark:border-gray-800">
|
|
<span className="px-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
|
{S.chat.sessionList}
|
|
</span>
|
|
<GroupModeToggle value={groupMode} onChange={setGroupMode} />
|
|
</div>
|
|
|
|
{groupMode === "agent" ? (
|
|
loading && agents.length === 0 ? (
|
|
<SkeletonList rows={5} />
|
|
) : (
|
|
orderedAgents.slice(0, groupCap).map((agent) => {
|
|
const parts = partitionSessions(byAgent.get(agent.agentId) ?? []);
|
|
const collapsed = collapsedGroups.has(agent.agentId);
|
|
const pinned = pinnedGroups.has(agent.agentId);
|
|
return (
|
|
<div key={agent.agentId} className="pt-2.5">
|
|
{/* Group header: collapse toggle (Agent name) + pin + new chat + Agent settings. */}
|
|
<GroupHeader
|
|
open={!collapsed}
|
|
onToggle={() => toggleGroup(agent.agentId)}
|
|
icon={
|
|
<AgentAvatar
|
|
id={agent.agentId}
|
|
name={agentDisplayName(agent)}
|
|
size={18}
|
|
className="shrink-0 rounded"
|
|
/>
|
|
}
|
|
label={agentDisplayName(agent)}
|
|
uppercase
|
|
actions={
|
|
<>
|
|
<GroupPinButton pinned={pinned} onToggle={() => togglePin(agent.agentId)} />
|
|
{/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */}
|
|
<button
|
|
type="button"
|
|
title={S.chat.newSessionMenu}
|
|
aria-label={S.chat.newSessionMenu}
|
|
onClick={() => newChat(agent.agentId)}
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
|
>
|
|
<Icon d="M12 5v14M5 12h14" size={18} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
title={S.agent.settings}
|
|
onClick={() => go(`/agents/${agent.agentId}`)}
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
|
>
|
|
<Icon d={GEAR_ICON} size={16} />
|
|
</button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{collapsed
|
|
? null
|
|
: renderGroupBody(
|
|
agent.agentId,
|
|
parts,
|
|
false,
|
|
countsByAgent.get(agent.agentId),
|
|
() => [agent.agentId],
|
|
)}
|
|
</div>
|
|
);
|
|
})
|
|
)
|
|
) : null}
|
|
{groupMode === "agent" && orderedAgents.length > groupCap
|
|
? moreGroupsRow(orderedAgents.length)
|
|
: null}
|
|
{groupMode === "agent" ? null : loading && sessions.length === 0 ? (
|
|
<SkeletonList rows={5} />
|
|
) : orderedWorkspaceGroups.length === 0 ? (
|
|
<p className="px-2.5 pt-3 text-xs text-gray-400 dark:text-gray-600">
|
|
{S.chat.noSessions}
|
|
</p>
|
|
) : (
|
|
orderedWorkspaceGroups.slice(0, groupCap).map((group) => {
|
|
const parts = partitionSessions(group.sessions);
|
|
const collapsed = collapsedGroups.has(group.key);
|
|
const pinned = pinnedGroups.has(group.key);
|
|
/** This group's exact server share (per-Workspace fold) and its per-category fetch fan-out. */
|
|
const counts = workspaceGroupCounts.get(group.key);
|
|
const contributingAgents = [...new Set(group.sessions.map((s) => s.agentId))];
|
|
const agentsFor = (category: SessionCategory) => [
|
|
...new Set([...(counts?.agents[category] ?? []), ...contributingAgents]),
|
|
];
|
|
return (
|
|
<div key={group.key} className="pt-2.5">
|
|
{/* Group header: collapse toggle (folder icon + directory basename + count, full
|
|
path in the tooltip; the count = the group's active conversations only, exact
|
|
server share, loaded rows win a disagreement — the folders never feed it) +
|
|
pin + new chat in this Workspace. */}
|
|
<GroupHeader
|
|
open={!collapsed}
|
|
onToggle={() => toggleGroup(group.key)}
|
|
icon={
|
|
/* Folder opens and closes with the group */
|
|
<span className="shrink-0 text-gray-400 dark:text-gray-500">
|
|
<Icon d={collapsed ? FOLDER_ICON : FOLDER_OPEN_ICON} size={15} />
|
|
</span>
|
|
}
|
|
label={group.temp ? S.chat.tempWorkspaces : group.label}
|
|
count={Math.max(counts?.totals.active ?? 0, parts.active.length)}
|
|
{...(group.fullPath !== null ? { title: group.fullPath } : {})}
|
|
actions={
|
|
<>
|
|
<GroupPinButton pinned={pinned} onToggle={() => togglePin(group.key)} />
|
|
{/* New chat in this Workspace: pre-fills the group's path in the draft ("" = auto temp directory); the Agent is the current one, falling back to default_agent */}
|
|
<button
|
|
type="button"
|
|
title={S.chat.newSessionInWorkspace}
|
|
aria-label={S.chat.newSessionInWorkspace}
|
|
onClick={() => newChat(workspaceNewChatAgentId, group.fullPath ?? "")}
|
|
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
|
>
|
|
<Icon d="M12 5v14M5 12h14" size={18} />
|
|
</button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
{/* A workspace group can span Agents: the group body fans folder loads and "More"
|
|
out per category to the Agents whose share of THIS group is non-zero (plus the
|
|
Agents already contributing loaded rows) — the active list and each folder
|
|
page independently. */}
|
|
{collapsed
|
|
? null
|
|
: renderGroupBody(group.key, parts, true, counts?.totals, agentsFor)}
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
{groupMode === "workspace" && orderedWorkspaceGroups.length > groupCap
|
|
? moreGroupsRow(orderedWorkspaceGroups.length)
|
|
: null}
|
|
</div>
|
|
|
|
{/* Bottom user config */}
|
|
<div className="shrink-0 border-t border-gray-200 p-2 dark:border-gray-800">
|
|
<Dropdown
|
|
open={userOpen}
|
|
setOpen={setUserOpen}
|
|
menuClass="bottom-full left-0 right-0 mb-1 origin-bottom"
|
|
button={
|
|
<button
|
|
type="button"
|
|
onClick={() => setUserOpen(!userOpen)}
|
|
className="flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-150 hover:bg-gray-200/70 dark:hover:bg-gray-800"
|
|
>
|
|
<span className="relative flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-gray-900 text-xs font-bold text-white dark:bg-gray-200 dark:text-gray-900">
|
|
{(user?.userId ?? "?").slice(0, 1).toUpperCase()}
|
|
{/* Update reminder dot: only once the lazy check has actually run and found a
|
|
newer release. The border (sidebar background color) separates it from the
|
|
avatar for every accent — the neutral accent matches the avatar fill. */}
|
|
{updateAvailable && (
|
|
<span
|
|
aria-hidden
|
|
className="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full border-2 border-gray-50 bg-[var(--accent-bg)] dark:border-gray-900"
|
|
/>
|
|
)}
|
|
</span>
|
|
<span className="min-w-0 flex-1 truncate text-sm font-medium">{user?.userId}</span>
|
|
{user?.isAdmin && (
|
|
<span className="text-xs text-gray-400 dark:text-gray-500">{S.auth.admin}</span>
|
|
)}
|
|
</button>
|
|
}
|
|
>
|
|
<div className="space-y-2.5 px-3 py-2">
|
|
<SettingRow label={S.settings.theme}>
|
|
<Segmented options={themeOptions} value={mode} onChange={setMode} />
|
|
</SettingRow>
|
|
<SettingRow label={S.settings.fontSize}>
|
|
<Segmented options={fontOptions} value={fontScale} onChange={setFontScale} />
|
|
</SettingRow>
|
|
<SettingRow label={S.settings.accent}>
|
|
<AccentPicker value={accent} onChange={setAccent} />
|
|
</SettingRow>
|
|
<SettingRow label={S.models.currency}>
|
|
<Segmented
|
|
options={currencyOptions}
|
|
value={currency}
|
|
onChange={setCurrency}
|
|
cols={2}
|
|
/>
|
|
</SettingRow>
|
|
<SettingRow label={S.settings.language}>
|
|
<Segmented options={langOptions} value={lang} onChange={setLang} />
|
|
</SettingRow>
|
|
{/* Off (default) = the sidebar lists only web-created Sessions, served straight
|
|
from the DB; on = CLI Sessions are discovered from the Trace directory too. */}
|
|
<SettingRow label={S.settings.showCliSessions}>
|
|
<Switch checked={showCliSessions} onChange={setShowCliSessions} />
|
|
</SettingRow>
|
|
</div>
|
|
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">
|
|
<button
|
|
type="button"
|
|
className={menuItemClass}
|
|
onClick={() => {
|
|
setUserOpen(false);
|
|
setChangePasswordOpen(true);
|
|
}}
|
|
>
|
|
{S.account.changePassword}
|
|
</button>
|
|
{/* THE update row — one button, two jobs, directly below Change password (owner
|
|
layout: the menu used to stack a release-notes link, an admin "Update now" row
|
|
and this check row on top of each other). It reads "Check for updates" and runs
|
|
the manual check until a newer release is known; from then on it reads "New
|
|
version vX available" with a leading accent dot and opens the update dialog
|
|
instead, which carries the release-notes link and the admin-only self-update.
|
|
The running version sits muted on the right — no product-name prefix, and no
|
|
superscript badge any more: the label itself already names the new version.
|
|
The "last updated" date lives in the row tooltip, keeping the row uncluttered.
|
|
While checking, the label swaps to the busy text and the version stays put.
|
|
Nothing is fetched until the menu first opens; the version span appears once
|
|
/api/version resolves. */}
|
|
{/* Hidden in desktop mode: updates are the desktop app's job (electron-updater),
|
|
and the dialog's admin self-update re-runs the CLI entry, which does not
|
|
exist under the desktop shell. */}
|
|
{!desktopMode && (
|
|
<button
|
|
type="button"
|
|
disabled={updateChecking}
|
|
onClick={() => {
|
|
if (newVersion !== null) {
|
|
setUserOpen(false);
|
|
setUpdateDialogOpen(true);
|
|
} else {
|
|
void runUpdateCheck();
|
|
}
|
|
}}
|
|
{...(versionDate !== null
|
|
? { title: S.update.lastUpdated(formatMonthDay(versionDate, locale)) }
|
|
: {})}
|
|
className={`${menuItemClass} flex items-center justify-between gap-2 disabled:cursor-default disabled:opacity-60`}
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2">
|
|
{updateChecking && (
|
|
<span
|
|
aria-hidden
|
|
className="inline-block h-3 w-3 shrink-0 animate-spin rounded-full border-[1.5px] border-current border-t-transparent opacity-70"
|
|
/>
|
|
)}
|
|
{!updateChecking && newVersion !== null && (
|
|
<span
|
|
aria-hidden
|
|
className="h-2 w-2 shrink-0 rounded-full bg-[var(--accent-bg)]"
|
|
/>
|
|
)}
|
|
<span className="min-w-0 truncate">
|
|
{updateChecking
|
|
? S.update.checking
|
|
: newVersion !== null
|
|
? S.update.newVersion(newVersion)
|
|
: S.update.checkNow}
|
|
</span>
|
|
</span>
|
|
{version !== null && (
|
|
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
|
|
{`v${version.version}`}
|
|
</span>
|
|
)}
|
|
</button>
|
|
)}
|
|
{/* User management is visible only to admins (the page route also has its own guard as a fallback). */}
|
|
{user?.isAdmin && (
|
|
<button
|
|
type="button"
|
|
className={menuItemClass}
|
|
onClick={() => {
|
|
setUserOpen(false);
|
|
go("/admin/users");
|
|
}}
|
|
>
|
|
{S.admin.users}
|
|
</button>
|
|
)}
|
|
{/* Hidden in desktop mode: the window IS the session — logging out would
|
|
strand the user on a login page whose password was never shown. */}
|
|
{!desktopMode && (
|
|
<button
|
|
type="button"
|
|
className="block w-full px-3.5 py-2 text-left text-sm text-red-600 transition-colors duration-150 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-950/40"
|
|
onClick={() => {
|
|
setUserOpen(false);
|
|
void logout().then(() => navigate("/login"));
|
|
}}
|
|
>
|
|
{S.auth.logout}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</Dropdown>
|
|
</div>
|
|
|
|
<ChangePasswordDialog
|
|
open={changePasswordOpen}
|
|
onClose={() => setChangePasswordOpen(false)}
|
|
/>
|
|
<UpdateDialog
|
|
open={updateDialogOpen}
|
|
onClose={() => setUpdateDialogOpen(false)}
|
|
latestVersion={newVersion}
|
|
releaseUrl={update?.releaseUrl ?? null}
|
|
canUpdate={user?.isAdmin === true}
|
|
/* A finished self-update makes the reminder stale, and the row stops offering the
|
|
manual check while a newer release is known — so re-check here, or the row would
|
|
still read "New version vX available" after updating to exactly that version, with
|
|
no way back short of reloading the page. Silent: the row's own change is the
|
|
feedback, and a toast would fire while the user is closing the dialog. */
|
|
onRunFinished={() => void forceUpdateCheck().catch(() => undefined)}
|
|
/>
|
|
|
|
<CreateProjectDialog
|
|
open={createProjectOpen}
|
|
onClose={() => setCreateProjectOpen(false)}
|
|
onCreated={(projectId) => {
|
|
setCreateProjectOpen(false);
|
|
void reloadProjects().then(() => setCurrentProjectId(projectId));
|
|
}}
|
|
/>
|
|
{currentProject && (
|
|
<ProjectSettingsDialog
|
|
open={projectSettingsOpen}
|
|
onClose={() => setProjectSettingsOpen(false)}
|
|
/>
|
|
)}
|
|
{/* Rename chat */}
|
|
<Modal
|
|
open={renamingSession !== null}
|
|
title={S.chat.renameSession}
|
|
onClose={() => (renameBusy ? undefined : setRenamingSession(null))}
|
|
footer={
|
|
<>
|
|
<Button onClick={() => setRenamingSession(null)} disabled={renameBusy}>
|
|
{S.common.cancel}
|
|
</Button>
|
|
<Button
|
|
variant="primary"
|
|
disabled={renameBusy || !renameText.trim()}
|
|
onClick={() => void confirmRename()}
|
|
>
|
|
{S.common.save}
|
|
</Button>
|
|
</>
|
|
}
|
|
>
|
|
<Input
|
|
label={S.chat.renameSessionLabel}
|
|
value={renameText}
|
|
error={renameError ?? undefined}
|
|
autoFocus
|
|
maxLength={120}
|
|
onChange={(e) => {
|
|
setRenameText(e.target.value);
|
|
if (renameError) setRenameError(null);
|
|
}}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && renameText.trim() && !renameBusy) void confirmRename();
|
|
}}
|
|
/>
|
|
</Modal>
|
|
|
|
{/* Delete chat confirmation (shared ConfirmModal) */}
|
|
<ConfirmModal
|
|
open={deletingSession !== null}
|
|
title={S.chat.deleteSession}
|
|
confirmLabel={S.common.delete}
|
|
busy={deletingBusy}
|
|
onClose={() => (deletingBusy ? undefined : setDeletingSession(null))}
|
|
onConfirm={() => void confirmDeleteSession()}
|
|
>
|
|
<p className="text-sm text-gray-600 dark:text-gray-300">
|
|
{deletingSession
|
|
? S.chat.deleteSessionConfirm(deletingSession.title ?? S.chat.defaultSessionTitle)
|
|
: ""}
|
|
</p>
|
|
</ConfirmModal>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Group-header pin toggle, shared by both grouping modes: revealed on header hover (or
|
|
* keyboard focus) while unpinned; once pinned it stays visible, doubling as the subtle
|
|
* pinned indicator. The header row carries the `group/header` scope so the reveal only
|
|
* reacts to its own row, not to the session rows' plain `group` scope.
|
|
* The accessible name stays STATIC and aria-pressed alone carries the state (the toggle
|
|
* pattern the grouping-mode buttons use) — a name that swaps Pin/Unpin alongside
|
|
* aria-pressed reads as "Unpin group, pressed", saying the state twice in conflicting
|
|
* ways. The title tooltip may still swap: it is presentation for pointer users and does
|
|
* not feed the accessible name while aria-label is present.
|
|
*/
|
|
function GroupPinButton({ pinned, onToggle }: { pinned: boolean; onToggle: () => void }) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
title={pinned ? S.nav.unpinGroup : S.nav.pinGroup}
|
|
aria-label={S.nav.pinGroup}
|
|
aria-pressed={pinned}
|
|
onClick={onToggle}
|
|
className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-md transition-all duration-150 hover:bg-gray-200/70 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200 ${
|
|
pinned
|
|
? "text-gray-500 dark:text-gray-400"
|
|
: "text-gray-400 opacity-0 focus-visible:opacity-100 group-hover/header:opacity-100 dark:text-gray-500"
|
|
}`}
|
|
>
|
|
<Icon d={PIN_ICON} size={15} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/** Single Session row: title + status dot/approval badge + hover action group (rename, archive/unarchive, delete). */
|
|
function SessionRow({
|
|
s,
|
|
active,
|
|
agentHint,
|
|
onOpen,
|
|
onRename,
|
|
onDelete,
|
|
onToggleArchive,
|
|
}: {
|
|
s: SessionInfo;
|
|
active: boolean;
|
|
/** Agent display name; when set (workspace mode) a small avatar keeps the Agent context visible on the row. */
|
|
agentHint?: string;
|
|
onOpen: (s: SessionInfo) => void;
|
|
onRename: (s: SessionInfo) => void;
|
|
onDelete: (s: SessionInfo) => void;
|
|
onToggleArchive: (s: SessionInfo) => void;
|
|
}) {
|
|
const actionBtn =
|
|
"flex h-6 w-6 shrink-0 items-center justify-center rounded text-gray-400 opacity-0 transition-all duration-150 focus-visible:opacity-100 group-hover:opacity-100";
|
|
return (
|
|
<li>
|
|
<div
|
|
className={`group flex items-center rounded-md pr-1 transition-colors duration-150 ${
|
|
active
|
|
? "bg-gray-200/70 dark:bg-gray-800"
|
|
: "hover:bg-gray-200/50 dark:hover:bg-gray-800/70"
|
|
}`}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => onOpen(s)}
|
|
className="flex min-w-0 flex-1 items-center gap-1.5 px-2.5 py-1.5 text-left"
|
|
>
|
|
{agentHint !== undefined && (
|
|
<span title={agentHint} className="flex shrink-0 items-center">
|
|
<AgentAvatar id={s.agentId} name={agentHint} size={14} className="rounded" />
|
|
{/* The avatar is aria-hidden and title only serves pointer users: expose the Agent name to keyboard/screen-reader users as visually hidden text inside the row button. */}
|
|
<span className="sr-only">{agentHint}</span>
|
|
</span>
|
|
)}
|
|
{/* Only attach a title attribute when the title is actually truncated (hover to see full text); don't duplicate the text otherwise. */}
|
|
<Truncated
|
|
text={s.title ?? S.chat.defaultSessionTitle}
|
|
className={`min-w-0 flex-1 text-sm ${
|
|
active
|
|
? "font-medium text-gray-900 dark:text-gray-100"
|
|
: s.archived
|
|
? "text-gray-400 dark:text-gray-500"
|
|
: "text-gray-700 dark:text-gray-300"
|
|
}`}
|
|
/>
|
|
{/* No per-row source tag: subagent / scheduled Sessions live in their own labelled, collapsed folders, so a badge on the title would just repeat the folder. */}
|
|
<StatusDot session={s} />
|
|
{s.pendingApprovalCount > 0 && (
|
|
<span title={S.chat.pendingApprovals(s.pendingApprovalCount)}>
|
|
<Badge tone="amber">{s.pendingApprovalCount}</Badge>
|
|
</span>
|
|
)}
|
|
</button>
|
|
{/* Action group: rename + archive/unarchive + delete */}
|
|
<div className="flex shrink-0 items-center">
|
|
<button
|
|
type="button"
|
|
title={S.chat.renameSession}
|
|
aria-label={S.chat.renameSession}
|
|
onClick={() => onRename(s)}
|
|
className={`${actionBtn} hover:bg-gray-300/60 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200`}
|
|
>
|
|
<Icon d="M4 20h4L18.5 9.5a2.1 2.1 0 0 0-3-3L5 17v3zM14 7l3 3" size={14} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
title={s.archived ? S.chat.unarchiveSession : S.chat.archiveSession}
|
|
aria-label={s.archived ? S.chat.unarchiveSession : S.chat.archiveSession}
|
|
onClick={() => onToggleArchive(s)}
|
|
className={`${actionBtn} hover:bg-gray-300/60 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200`}
|
|
>
|
|
<Icon
|
|
d={
|
|
s.archived
|
|
? "M3 8h18M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M4 8l1.5-3h13L20 8M12 17v-5m-2.5 2L12 11l2.5 3"
|
|
: "M3 8h18M5 8v11a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V8M4 8l1.5-3h13L20 8M9.5 13.5 12 16l2.5-2.5"
|
|
}
|
|
size={14}
|
|
/>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
title={S.chat.deleteSession}
|
|
aria-label={S.chat.deleteSession}
|
|
onClick={() => onDelete(s)}
|
|
className={`${actionBtn} hover:bg-gray-300/60 hover:text-red-600 dark:hover:bg-gray-700 dark:hover:text-red-400`}
|
|
>
|
|
{/* Trash can: lid (4..20), handle, body (5..19, symmetric sides), two vertical ribs */}
|
|
<Icon
|
|
d="M4 6h16M9 6V4.5A1.5 1.5 0 0 1 10.5 3h3A1.5 1.5 0 0 1 15 4.5V6M6 6v13a2 2 0 0 0 2 2h8a2 2 0 0 0 2-2V6M10 10.5v6M14 10.5v6"
|
|
size={14}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
);
|
|
}
|
|
|
|
function SettingRow({ label, children }: { label: string; children: ReactNode }) {
|
|
return (
|
|
<div>
|
|
<p className="mb-1 text-[11px] font-medium text-gray-500 dark:text-gray-400">{label}</p>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Accent color picker: a row of swatches, with a ring on the selected one. */
|
|
function AccentPicker({ value, onChange }: { value: Accent; onChange: (a: Accent) => void }) {
|
|
return (
|
|
<div className="flex items-center gap-1.5">
|
|
{ACCENT_SWATCHES.map((s) => (
|
|
<button
|
|
key={s.value}
|
|
type="button"
|
|
title={S.settings.accentNames[s.value]}
|
|
aria-label={S.settings.accentNames[s.value]}
|
|
aria-pressed={value === s.value}
|
|
onClick={() => onChange(s.value)}
|
|
className={`h-5 w-5 rounded-full border transition-transform duration-150 hover:scale-110 ${
|
|
value === s.value
|
|
? "border-gray-500 ring-2 ring-gray-400/50 dark:border-gray-300"
|
|
: "border-transparent"
|
|
}`}
|
|
style={{ backgroundColor: s.color }}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|