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:
@@ -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