feat(web): group the chat sidebar by workspace with an agent-mode toggle (#26)
Co-authored-by: Alice <alice@prismshadow.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,15 +1,19 @@
|
||||
/**
|
||||
* Single-column sidebar, top to bottom:
|
||||
* Project switcher -> new chat (default_agent draft) + fixed nav (Agents / models / cost center /
|
||||
* Trace) -> Session area grouped by Agent (group header = Agent name + new chat + Agent settings;
|
||||
* Trace) -> Session area with two grouping modes (a small toggle in the section header; the
|
||||
* choice and each Project's group collapse 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) -> 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): 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.
|
||||
* 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 { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { NavLink, useMatch, useNavigate } from "react-router";
|
||||
import type { SessionInfo } from "@prismshadow/penguin-server/api";
|
||||
@@ -23,6 +27,7 @@ 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 { groupSessionsByWorkspace, workspaceGroupKey } from "../../lib/session-grouping";
|
||||
import { Dropdown } from "../ui/dropdown";
|
||||
import { AgentAvatar } from "../ui/agent-avatar";
|
||||
import { Chevron } from "../ui/chevron";
|
||||
@@ -81,9 +86,47 @@ const NAV_ICONS = {
|
||||
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";
|
||||
|
||||
/** Folder outline (same glyph as the draft page's Workspace pill). */
|
||||
const FOLDER_ICON = "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z";
|
||||
|
||||
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";
|
||||
|
||||
/** Grouping mode of the Session list (persisted; Workspace is the default). */
|
||||
type GroupMode = "workspace" | "agent";
|
||||
const GROUP_MODE_KEY = "penguin.sidebarGroupMode";
|
||||
function initialGroupMode(): GroupMode {
|
||||
return localStorage.getItem(GROUP_MODE_KEY) === "agent" ? "agent" : "workspace";
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed-group persistence (survives a refresh), one key per Project — group keys
|
||||
* are Agent ids / Workspace paths, which are Project-scoped. Both grouping modes share
|
||||
* one set (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}`;
|
||||
/** Reads the persisted collapsed set (corrupted storage degrades to "all expanded"). */
|
||||
function loadCollapsedGroups(projectId: string | null): ReadonlySet<string> {
|
||||
if (!projectId) return new Set();
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(localStorage.getItem(collapsedGroupsKey(projectId)) ?? "[]");
|
||||
return new Set(
|
||||
Array.isArray(parsed) ? parsed.filter((x): x is string => typeof x === "string") : [],
|
||||
);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
function saveCollapsedGroups(projectId: string | null, next: ReadonlySet<string>): void {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
localStorage.setItem(collapsedGroupsKey(projectId), JSON.stringify([...next]));
|
||||
} catch {
|
||||
/* best-effort persistence (quota/private mode) */
|
||||
}
|
||||
}
|
||||
|
||||
/** Session status dot: running pulses green, compacting shows an amber dot; idle shows nothing. */
|
||||
function StatusDot({ session }: { session: SessionInfo }) {
|
||||
if (session.status === "running") {
|
||||
@@ -123,9 +166,10 @@ export function Sidebar({
|
||||
setCurrentProjectId,
|
||||
reloadProjects,
|
||||
agents,
|
||||
currentAgent,
|
||||
setCurrentAgentId,
|
||||
} = useProject();
|
||||
const { byAgent, loading, remove, replace } = useSessions();
|
||||
const { sessions, byAgent, loading, remove, replace } = useSessions();
|
||||
const chatMatch = useMatch("/chat/:sessionId");
|
||||
const activeSessionId = chatMatch?.params.sessionId ?? null;
|
||||
|
||||
@@ -134,9 +178,18 @@ export function Sidebar({
|
||||
const [createProjectOpen, setCreateProjectOpen] = useState(false);
|
||||
const [projectSettingsOpen, setProjectSettingsOpen] = useState(false);
|
||||
const [changePasswordOpen, setChangePasswordOpen] = useState(false);
|
||||
/** Collapsed Agent groups (expanded by default). */
|
||||
const [collapsedAgents, setCollapsedAgents] = useState<ReadonlySet<string>>(new Set());
|
||||
/** Expanded "archived" groups (collapsed by default). */
|
||||
const currentProjectId = currentProject?.projectId ?? null;
|
||||
/** 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>>(() =>
|
||||
loadCollapsedGroups(currentProjectId),
|
||||
);
|
||||
// Project resolved on first load / switched: swap in that Project's persisted collapse set.
|
||||
useEffect(() => {
|
||||
setCollapsedGroups(loadCollapsedGroups(currentProjectId));
|
||||
}, [currentProjectId]);
|
||||
/** Expanded "archived" groups (collapsed by default), keyed like collapsedGroups. */
|
||||
const [openArchived, setOpenArchived] = useState<ReadonlySet<string>>(new Set());
|
||||
/** Session pending delete confirmation (null = none). */
|
||||
const [deletingSession, setDeletingSession] = useState<SessionInfo | null>(null);
|
||||
@@ -148,19 +201,33 @@ export function Sidebar({
|
||||
const [renameBusy, setRenameBusy] = useState(false);
|
||||
const [renameError, setRenameError] = useState<string | null>(null);
|
||||
|
||||
const toggleAgent = (agentId: string) =>
|
||||
setCollapsedAgents((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(agentId)) next.delete(agentId);
|
||||
else next.add(agentId);
|
||||
return next;
|
||||
});
|
||||
const setGroupMode = (mode: GroupMode) => {
|
||||
localStorage.setItem(GROUP_MODE_KEY, mode);
|
||||
setGroupModeState(mode);
|
||||
};
|
||||
|
||||
const toggleArchivedGroup = (agentId: string) =>
|
||||
/** Workspace groups (workspace mode): computed from the flat list, temp directories merged last. */
|
||||
const workspaceGroups = useMemo(() => groupSessionsByWorkspace(sessions), [sessions]);
|
||||
|
||||
/** 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);
|
||||
saveCollapsedGroups(currentProjectId, next);
|
||||
};
|
||||
|
||||
const toggleArchivedGroup = (key: string) =>
|
||||
setOpenArchived((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(agentId)) next.delete(agentId);
|
||||
else next.add(agentId);
|
||||
if (next.has(key)) next.delete(key);
|
||||
else next.add(key);
|
||||
return next;
|
||||
});
|
||||
|
||||
@@ -168,7 +235,7 @@ export function Sidebar({
|
||||
const toggleArchive = async (s: SessionInfo) => {
|
||||
// Archiving the currently open chat: expand the "archived" group so it doesn't silently vanish from the sidebar with no way back.
|
||||
if (!s.archived && s.sessionId === activeSessionId) {
|
||||
setOpenArchived((prev) => new Set(prev).add(s.agentId));
|
||||
setOpenArchived((prev) => new Set(prev).add(sessionGroupKey(s)));
|
||||
}
|
||||
try {
|
||||
const res = await api.patchSession(s.sessionId, { archived: !s.archived });
|
||||
@@ -231,25 +298,101 @@ export function Sidebar({
|
||||
* 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 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).
|
||||
* 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) => {
|
||||
const newChat = (agentId?: string, workspace?: string) => {
|
||||
if (agentId) setCurrentAgentId(agentId);
|
||||
navigate(`/chat/${DRAFT_SESSION_ID}`, agentId ? { state: { agentId } } : undefined);
|
||||
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) => {
|
||||
setDeleteError(null);
|
||||
setDeletingSession(x);
|
||||
}}
|
||||
onToggleArchive={(x) => void toggleArchive(x)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
|
||||
/** Expanded group body shared by both modes: active rows + the collapsed-by-default archived subgroup (keyed by the group key). */
|
||||
const renderGroupBody = (
|
||||
groupKey: string,
|
||||
activeList: SessionInfo[],
|
||||
archivedList: SessionInfo[],
|
||||
withAgentHint: boolean,
|
||||
) => {
|
||||
const archivedOpen = openArchived.has(groupKey);
|
||||
return (
|
||||
<>
|
||||
{activeList.length === 0 && archivedList.length === 0 ? (
|
||||
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">
|
||||
{S.chat.noSessions}
|
||||
</p>
|
||||
) : (
|
||||
renderRows(activeList, withAgentHint)
|
||||
)}
|
||||
|
||||
{/* Archived group (collapsed by default) */}
|
||||
{archivedList.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleArchivedGroup(groupKey)}
|
||||
className="flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<Chevron open={archivedOpen} size={12} />
|
||||
{S.chat.archivedGroup(archivedList.length)}
|
||||
</button>
|
||||
{archivedOpen && renderRows(archivedList, withAgentHint)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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 },
|
||||
@@ -396,126 +539,146 @@ export function Sidebar({
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Session area grouped by Agent (scrollable) */}
|
||||
{/* Session area (scrollable): grouped by Workspace (default) or by Agent */}
|
||||
<div className="mt-3 min-h-0 flex-1 overflow-y-auto border-t border-gray-200 px-2 pb-2 dark:border-gray-800">
|
||||
{loading && agents.length === 0 ? (
|
||||
{/* Section header: list label + grouping-mode toggle (the choice persists in localStorage) */}
|
||||
<div className="flex items-center justify-between px-1 pt-2">
|
||||
<span className="px-1 text-[11px] font-semibold uppercase tracking-wide text-gray-400 dark:text-gray-500">
|
||||
{S.chat.sessionList}
|
||||
</span>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{(
|
||||
[
|
||||
{ value: "workspace", icon: FOLDER_ICON, label: S.chat.groupByWorkspace },
|
||||
{ value: "agent", icon: NAV_ICONS.agents, label: S.chat.groupByAgent },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
title={opt.label}
|
||||
aria-label={opt.label}
|
||||
aria-pressed={groupMode === opt.value}
|
||||
onClick={() => setGroupMode(opt.value)}
|
||||
className={`flex h-6 w-6 shrink-0 items-center justify-center rounded-md transition-colors duration-150 ${
|
||||
groupMode === opt.value
|
||||
? "bg-gray-200/70 text-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||||
: "text-gray-400 hover:bg-gray-200/50 hover:text-gray-700 dark:text-gray-500 dark:hover:bg-gray-800/70 dark:hover:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<Icon d={opt.icon} size={14} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{groupMode === "agent" ? (
|
||||
loading && agents.length === 0 ? (
|
||||
<SkeletonList rows={5} />
|
||||
) : (
|
||||
agents.map((agent) => {
|
||||
const list = byAgent.get(agent.agentId) ?? [];
|
||||
const activeList = list.filter((s) => !s.archived);
|
||||
const archivedList = list.filter((s) => s.archived);
|
||||
const collapsed = collapsedGroups.has(agent.agentId);
|
||||
return (
|
||||
<div key={agent.agentId} className="pt-2.5">
|
||||
{/* Group header: collapse toggle (Agent name) + new chat + Agent settings */}
|
||||
<div className="flex items-center gap-0.5 px-1 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleGroup(agent.agentId)}
|
||||
aria-label={collapsed ? S.nav.expandGroup : S.nav.collapseGroup}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<AgentAvatar
|
||||
id={agent.agentId}
|
||||
name={agentDisplayName(agent)}
|
||||
size={18}
|
||||
className="shrink-0 rounded"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{agentDisplayName(agent)}
|
||||
</span>
|
||||
{/* Expand/collapse indicator sits right after the Agent name */}
|
||||
<Chevron open={!collapsed} size={12} className="text-gray-400" />
|
||||
<span className="min-w-0 flex-1" />
|
||||
</button>
|
||||
{/* 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>
|
||||
</div>
|
||||
|
||||
{collapsed
|
||||
? null
|
||||
: renderGroupBody(agent.agentId, activeList, archivedList, false)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)
|
||||
) : loading && sessions.length === 0 ? (
|
||||
<SkeletonList rows={5} />
|
||||
) : workspaceGroups.length === 0 ? (
|
||||
<p className="px-2.5 pt-3 text-xs text-gray-400 dark:text-gray-600">
|
||||
{S.chat.noSessions}
|
||||
</p>
|
||||
) : (
|
||||
agents.map((agent) => {
|
||||
const list = byAgent.get(agent.agentId) ?? [];
|
||||
const activeList = list.filter((s) => !s.archived);
|
||||
const archivedList = list.filter((s) => s.archived);
|
||||
const collapsed = collapsedAgents.has(agent.agentId);
|
||||
const archivedOpen = openArchived.has(agent.agentId);
|
||||
workspaceGroups.map((group) => {
|
||||
const activeList = group.sessions.filter((s) => !s.archived);
|
||||
const archivedList = group.sessions.filter((s) => s.archived);
|
||||
const collapsed = collapsedGroups.has(group.key);
|
||||
return (
|
||||
<div key={agent.agentId} className="pt-2.5">
|
||||
{/* Group header: collapse toggle (Agent name) + new chat + Agent settings */}
|
||||
<div key={group.key} className="pt-2.5">
|
||||
{/* Group header: collapse toggle (folder icon + directory basename + count, full path in the tooltip) + new chat in this Workspace */}
|
||||
<div className="flex items-center gap-0.5 px-1 pb-0.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleAgent(agent.agentId)}
|
||||
onClick={() => toggleGroup(group.key)}
|
||||
aria-label={collapsed ? S.nav.expandGroup : S.nav.collapseGroup}
|
||||
{...(group.fullPath !== null ? { title: group.fullPath } : {})}
|
||||
className="flex min-w-0 flex-1 items-center gap-1 rounded px-1 py-0.5 text-left transition-colors duration-150 hover:bg-gray-200/50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<AgentAvatar
|
||||
id={agent.agentId}
|
||||
name={agentDisplayName(agent)}
|
||||
size={18}
|
||||
className="shrink-0 rounded"
|
||||
/>
|
||||
<span className="min-w-0 truncate text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{agentDisplayName(agent)}
|
||||
<span className="shrink-0 text-gray-400 dark:text-gray-500">
|
||||
<Icon d={FOLDER_ICON} size={15} />
|
||||
</span>
|
||||
{/* No uppercase transform: a directory basename's casing is meaningful */}
|
||||
<span className="min-w-0 truncate text-xs font-semibold text-gray-500 dark:text-gray-400">
|
||||
{group.temp ? S.chat.tempWorkspaces : group.label}
|
||||
</span>
|
||||
<span className="shrink-0 text-[11px] text-gray-400 dark:text-gray-500">
|
||||
{activeList.length}
|
||||
</span>
|
||||
{/* Expand/collapse indicator sits right after the Agent name */}
|
||||
<Chevron open={!collapsed} size={12} className="text-gray-400" />
|
||||
<span className="min-w-0 flex-1" />
|
||||
</button>
|
||||
{/* New chat: enters draft state directly with this group's Agent (all options live on the draft input card) */}
|
||||
{/* 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.newSessionMenu}
|
||||
aria-label={S.chat.newSessionMenu}
|
||||
onClick={() => newChat(agent.agentId)}
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{collapsed ? null : (
|
||||
<>
|
||||
{activeList.length === 0 && archivedList.length === 0 ? (
|
||||
<p className="px-2.5 py-1 text-xs text-gray-400 dark:text-gray-600">
|
||||
{S.chat.noSessions}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
{activeList.map((s) => (
|
||||
<SessionRow
|
||||
key={s.sessionId}
|
||||
s={s}
|
||||
active={s.sessionId === activeSessionId}
|
||||
onOpen={openSession}
|
||||
onRename={(x) => {
|
||||
setRenameError(null);
|
||||
setRenameText(x.title ?? "");
|
||||
setRenamingSession(x);
|
||||
}}
|
||||
onDelete={(x) => {
|
||||
setDeleteError(null);
|
||||
setDeletingSession(x);
|
||||
}}
|
||||
onToggleArchive={(x) => void toggleArchive(x)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{/* Archived group (collapsed by default) */}
|
||||
{archivedList.length > 0 && (
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => toggleArchivedGroup(agent.agentId)}
|
||||
className="flex w-full items-center gap-1 rounded px-1.5 py-1 text-left text-[11px] font-medium text-gray-400 transition-colors duration-150 hover:bg-gray-200/50 dark:text-gray-500 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<Chevron open={archivedOpen} size={12} />
|
||||
{S.chat.archivedGroup(archivedList.length)}
|
||||
</button>
|
||||
{archivedOpen && (
|
||||
<ul className="space-y-0.5">
|
||||
{archivedList.map((s) => (
|
||||
<SessionRow
|
||||
key={s.sessionId}
|
||||
s={s}
|
||||
active={s.sessionId === activeSessionId}
|
||||
onOpen={openSession}
|
||||
onRename={(x) => {
|
||||
setRenameError(null);
|
||||
setRenameText(x.title ?? "");
|
||||
setRenamingSession(x);
|
||||
}}
|
||||
onDelete={(x) => {
|
||||
setDeleteError(null);
|
||||
setDeletingSession(x);
|
||||
}}
|
||||
onToggleArchive={(x) => void toggleArchive(x)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{collapsed ? null : renderGroupBody(group.key, activeList, archivedList, true)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
@@ -695,6 +858,7 @@ export function Sidebar({
|
||||
function SessionRow({
|
||||
s,
|
||||
active,
|
||||
agentHint,
|
||||
onOpen,
|
||||
onRename,
|
||||
onDelete,
|
||||
@@ -702,6 +866,8 @@ function SessionRow({
|
||||
}: {
|
||||
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;
|
||||
@@ -723,6 +889,13 @@ function SessionRow({
|
||||
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}
|
||||
|
||||
@@ -17,8 +17,10 @@
|
||||
* change before unmount, one final write is flushed) — closing and returning to
|
||||
* the page resumes where you left off; the cache is cleared on successful send.
|
||||
* The sidebar group header "+" / menu "New conversation" explicitly specify an
|
||||
* Agent via route state (overriding the cached selection); a direct visit or
|
||||
* refresh falls back to the cache.
|
||||
* Agent via route state (overriding the cached selection); the workspace-mode
|
||||
* group header "+" additionally carries a Workspace path pre-filling the
|
||||
* Workspace selection ("" = auto temp directory). A direct visit or refresh
|
||||
* falls back to the cache.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate } from "react-router";
|
||||
@@ -54,6 +56,32 @@ import { sameModelRef } from "../models/model-grouping";
|
||||
/** Coalescing window for writing body text to the cache: keystrokes are frequent, so a short batch accumulates before persisting (option changes are still written immediately). */
|
||||
const DRAFT_SAVE_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* "Applied" markers for the route-state overrides (one slot per field, holding the last
|
||||
* consumed location.key). React Router persists location.state AND location.key in
|
||||
* history.state, which survives a full page reload, while a ref resets with the JS
|
||||
* context — with a ref alone, a reload would re-apply the override and clobber whatever
|
||||
* the user changed since (restored from the draft cache). sessionStorage is per-tab
|
||||
* exactly like history.state, so the marker follows the history entry; on storage
|
||||
* failure (private mode) both helpers degrade to "not consumed", and the in-component
|
||||
* ref still provides the previous apply-once-per-mount behavior.
|
||||
*/
|
||||
type RouteStateField = "agentId" | "workspace";
|
||||
function loadAppliedRouteKey(field: RouteStateField): string | null {
|
||||
try {
|
||||
return sessionStorage.getItem(`penguin.chatRouteApplied.${field}`);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function saveAppliedRouteKey(field: RouteStateField, key: string): void {
|
||||
try {
|
||||
sessionStorage.setItem(`penguin.chatRouteApplied.${field}`, key);
|
||||
} catch {
|
||||
/* best-effort: the dedup marker falls back to the per-mount ref */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Example tasks on the draft screen, in display order (game card first, LoL music player,
|
||||
* then the RAG build). Copy lives in S.chat.exampleTasks[id]; skills are pinned via a
|
||||
@@ -124,14 +152,20 @@ export function DraftView({
|
||||
// on invalid value" effect would let the former write B in one render while the
|
||||
// latter, still judging by the stale closure's invalid value, writes the default
|
||||
// Agent and clobbers B.
|
||||
const stateAgentId = (location.state as { agentId?: string } | null)?.agentId;
|
||||
const routeState = location.state as { agentId?: string; workspace?: string } | null;
|
||||
const stateAgentId = routeState?.agentId;
|
||||
const appliedStateKey = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (agents.length === 0) return; // list not ready yet, nothing to validate against — wait for the next pass
|
||||
const valid = (id: string | null | undefined): id is string =>
|
||||
!!id && agents.some((a) => a.agentId === id);
|
||||
if (stateAgentId && appliedStateKey.current !== location.key) {
|
||||
if (
|
||||
stateAgentId &&
|
||||
appliedStateKey.current !== location.key &&
|
||||
loadAppliedRouteKey("agentId") !== location.key
|
||||
) {
|
||||
appliedStateKey.current = location.key;
|
||||
saveAppliedRouteKey("agentId", location.key);
|
||||
if (valid(stateAgentId)) {
|
||||
setAgentId(stateAgentId);
|
||||
return;
|
||||
@@ -141,6 +175,25 @@ export function DraftView({
|
||||
setAgentId((agents.find((a) => a.agentId === "default_agent") ?? agents[0])?.agentId ?? null);
|
||||
}, [agents, agentId, location.key, stateAgentId]);
|
||||
|
||||
// Explicit Workspace from route state (the workspace-mode group header "+"): applied once per
|
||||
// location.key, same convention as the Agent above, overriding the cached selection ("" pre-fills
|
||||
// the auto temp directory). Unlike the Agent there's no list to validate against, so this is a
|
||||
// separate effect that never has to wait for a load.
|
||||
const stateWorkspace = routeState?.workspace;
|
||||
const appliedWorkspaceKey = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (
|
||||
stateWorkspace === undefined ||
|
||||
appliedWorkspaceKey.current === location.key ||
|
||||
loadAppliedRouteKey("workspace") === location.key
|
||||
) {
|
||||
return;
|
||||
}
|
||||
appliedWorkspaceKey.current = location.key;
|
||||
saveAppliedRouteKey("workspace", location.key);
|
||||
setWorkspace(stateWorkspace);
|
||||
}, [location.key, stateWorkspace]);
|
||||
|
||||
// Model fallback: once config is ready, if nothing is selected or the selection is no longer valid, fall back to the project default → the first model (always as a paired reference).
|
||||
useEffect(() => {
|
||||
if (!models) return;
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Pure grouping logic for the chat sidebar's "by Workspace" mode.
|
||||
*
|
||||
* There is no Workspace entity on the server: a Session only carries the plain
|
||||
* filesystem path locked in at creation (SessionInfo.workspace), so grouping works
|
||||
* on those path strings. Sessions created without an explicit Workspace get an
|
||||
* auto-created temp directory shaped like `<agentDir>/workspaces/tmp-<8hex>`
|
||||
* (packages/core/src/internal/session-support.ts, createTempWorkspace); each of
|
||||
* those is single-use, so per-path groups would be one-session noise — they are all
|
||||
* merged into ONE trailing "temp workspaces" group instead.
|
||||
*/
|
||||
import type { SessionInfo } from "@prismshadow/penguin-server/api";
|
||||
|
||||
/** Group key of the merged auto-temp group ("\0" can never appear in a filesystem path, so it never collides with a real Workspace). */
|
||||
export const TEMP_WORKSPACE_GROUP_KEY = "\0temp-workspaces";
|
||||
|
||||
/** Auto-created temp Workspace tail: `workspaces/tmp-<8hex>` (either path separator; core supports win32). */
|
||||
const TEMP_WORKSPACE_RE = /[/\\]workspaces[/\\]tmp-[0-9a-f]{8}$/;
|
||||
|
||||
/**
|
||||
* Whether a Session's Workspace is an auto-created temp directory. An empty path
|
||||
* also counts as "auto temp": the server always backfills the resolved path, so
|
||||
* this is defensive only.
|
||||
*/
|
||||
export function isTempWorkspace(workspace: string): boolean {
|
||||
const p = workspace.trim();
|
||||
return p === "" || TEMP_WORKSPACE_RE.test(p);
|
||||
}
|
||||
|
||||
/** Stable group key for a Session's Workspace (collapse state / React key): the path itself, or the temp sentinel. */
|
||||
export function workspaceGroupKey(workspace: string): string {
|
||||
return isTempWorkspace(workspace) ? TEMP_WORKSPACE_GROUP_KEY : workspace.trim();
|
||||
}
|
||||
|
||||
/** Short display label: the last path segment (the filesystem root yields "/"). */
|
||||
export function workspaceLabel(workspace: string): string {
|
||||
const parts = workspace
|
||||
.trim()
|
||||
.split(/[/\\]+/)
|
||||
.filter(Boolean);
|
||||
return parts[parts.length - 1] ?? "/";
|
||||
}
|
||||
|
||||
export interface WorkspaceGroup {
|
||||
/** Stable group key: the Workspace path, or TEMP_WORKSPACE_GROUP_KEY for the merged temp group. */
|
||||
key: string;
|
||||
/** Display label: the path basename; empty for the temp group (the sidebar renders the localized name). */
|
||||
label: string;
|
||||
/** Full path for tooltips; null for the merged temp group (its members' paths all differ). */
|
||||
fullPath: string | null;
|
||||
/** True for the merged auto-temp group. */
|
||||
temp: boolean;
|
||||
/** Member Sessions, newest first (createdAt desc). */
|
||||
sessions: SessionInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups Sessions by their Workspace path. Named groups are sorted by their newest
|
||||
* Session's createdAt desc; the merged temp group (if any) always comes last.
|
||||
* Sessions inside a group are re-sorted newest first — the flat store list
|
||||
* concatenates per-Agent server responses, so its order isn't globally chronological.
|
||||
* createdAt is a uniform ISO-8601 UTC string (server: `new Date().toISOString()`),
|
||||
* so lexicographic comparison equals chronological comparison.
|
||||
*/
|
||||
export function groupSessionsByWorkspace(sessions: SessionInfo[]): WorkspaceGroup[] {
|
||||
const byKey = new Map<string, WorkspaceGroup>();
|
||||
for (const s of sessions) {
|
||||
const key = workspaceGroupKey(s.workspace);
|
||||
let group = byKey.get(key);
|
||||
if (!group) {
|
||||
const temp = key === TEMP_WORKSPACE_GROUP_KEY;
|
||||
group = {
|
||||
key,
|
||||
label: temp ? "" : workspaceLabel(s.workspace),
|
||||
fullPath: temp ? null : s.workspace.trim(),
|
||||
temp,
|
||||
sessions: [],
|
||||
};
|
||||
byKey.set(key, group);
|
||||
}
|
||||
group.sessions.push(s);
|
||||
}
|
||||
const byCreatedDesc = (a: string, b: string) => (a < b ? 1 : a > b ? -1 : 0);
|
||||
const groups = [...byKey.values()];
|
||||
for (const g of groups) g.sessions.sort((a, b) => byCreatedDesc(a.createdAt, b.createdAt));
|
||||
groups.sort((a, b) => {
|
||||
if (a.temp !== b.temp) return a.temp ? 1 : -1;
|
||||
return byCreatedDesc(a.sessions[0]?.createdAt ?? "", b.sessions[0]?.createdAt ?? "");
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
@@ -486,6 +486,11 @@ export const en: Strings = {
|
||||
workspaceAuto: "Auto temp directory",
|
||||
workspaceClear: "Use auto temp directory instead",
|
||||
workspaceDirInvalid: "Directory does not exist or is inaccessible; reverted",
|
||||
/** Sidebar conversation-list grouping toggle (workspace is the default) + workspace groups. */
|
||||
groupByWorkspace: "Group by workspace",
|
||||
groupByAgent: "Group by agent",
|
||||
tempWorkspaces: "Temp workspaces",
|
||||
newSessionInWorkspace: "New chat in this workspace",
|
||||
draftSubtitle: "The self-evolving agent that excels at AI development tasks",
|
||||
exampleTasks: {
|
||||
game: {
|
||||
|
||||
@@ -464,6 +464,11 @@ export const zh = {
|
||||
workspaceAuto: "自动临时目录",
|
||||
workspaceClear: "改用自动临时目录",
|
||||
workspaceDirInvalid: "目录不存在或无法访问,已回退",
|
||||
/** 侧栏对话列表的分组切换(默认按工作区)与工作区分组。 */
|
||||
groupByWorkspace: "按工作区分组",
|
||||
groupByAgent: "按 Agent 分组",
|
||||
tempWorkspaces: "临时工作区",
|
||||
newSessionInWorkspace: "在此工作区新建对话",
|
||||
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
|
||||
/**
|
||||
* Example task cards on the draft screen: one click auto-submits the canned prompt (game
|
||||
|
||||
Reference in New Issue
Block a user