-
-
+
+
{
setName(event.currentTarget.value);
@@ -292,16 +266,14 @@ export function WorkspaceCloneModal({
}}
/>
-
-
+
+
- {!cloningEnabled && cloneDisabledMessage ? (
-
{cloneDisabledMessage}
- ) : null}
- ) : null}
- {error ?
{error}
: null}
-
-
-
-
- {mode === "folder" ? (
-
+
+
+
);
diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts
index 08882fd64..948c95ebb 100644
--- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts
+++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts
@@ -143,14 +143,14 @@ export const EN_US_COMMON_TRANSLATIONS = {
"The folder holds too many files (over {max}); trim it and retry",
"chat.workspaceDropTooLarge": "The folder is larger than {max} MiB; trim it and retry",
"chat.workspaceMountDropNoProject":
- "Select a workspace before dropping folders to mount them as project directories",
+ "Select a workspace before dropping folders to mount them as workspace directories",
"chat.workspaceMountDropSuccess":
- "Mounted {count} folder(s) as read-only project directories (adjust in project settings)",
+ "Mounted {count} folder(s) as read-only workspace directories (adjust in workspace settings)",
"chat.workspaceMountDropSkippedInside":
"Skipped {count} folder(s) already inside the current workspace",
"chat.workspaceMountDropSkippedOverlap":
- "Skipped {count} folder(s) overlapping existing project directories",
- "chat.workspaceMountDropFailed": "Failed to mount project directories",
+ "Skipped {count} folder(s) overlapping existing workspace directories",
+ "chat.workspaceMountDropFailed": "Failed to mount workspace directories",
"chat.workspaceCreate": "New workspace",
"chat.workspaceAdd": "Add…",
"chat.workspaceUngrouped": "Ungrouped",
@@ -160,7 +160,7 @@ export const EN_US_COMMON_TRANSLATIONS = {
"chat.workspaceGroupDelete": "Delete group",
"chat.workspaceGroupDeleteConfirmTitle": 'Delete group "{name}"?',
"chat.workspaceGroupDeleteConfirmDescription":
- "Projects in the group move back to ungrouped; the projects themselves are not deleted.",
+ "Workspaces in the group move back to ungrouped; the workspaces themselves are not deleted.",
"chat.workspaceGroupToggle": "Toggle group",
"chat.workspaceGroupActions": "Group actions",
"chat.workspaceGroupMove": "Move to group",
@@ -169,6 +169,7 @@ export const EN_US_COMMON_TRANSLATIONS = {
"Open an existing folder or create a new workspace from a remote Git repository.",
"chat.workspaceOpenFolder": "Open local folder",
"chat.workspaceOpenFolderDescription": "Add an existing folder as a workspace.",
+ "chat.workspaceOr": "or",
"chat.workspaceOpenFolderSubmit": "Select",
"chat.workspaceCloneRepository": "Clone Git repository",
"chat.workspaceCloneDescription": "The repository opens as a workspace when cloning finishes.",
@@ -199,29 +200,29 @@ export const EN_US_COMMON_TRANSLATIONS = {
"chat.workspaceNewConversation": "New conversation",
"chat.workspaceMore": "More",
"chat.workspaceConfigure": "Configure",
- "chat.projectPromptTitle": "Project prompt",
+ "chat.projectPromptTitle": "Workspace prompt",
"chat.globalPromptTitle": "Global prompt",
"chat.projectPromptStrategy": "Relationship to the global prompt",
"chat.projectPromptStrategyHint":
- "Choose how this project prompt combines with the active global template.",
+ "Choose how this workspace prompt combines with the active global template.",
"chat.projectPromptAppend": "Append",
"chat.projectPromptAppendHint":
- "Apply the active global prompt first, then add this project's instructions.",
+ "Apply the active global prompt first, then add this workspace's instructions.",
"chat.projectPromptReplace": "Replace",
"chat.projectPromptReplaceHint":
- "Use only the project prompt here and leave out the global prompt.",
- "chat.projectPromptContentHint": "Leave empty to use global prompts only for this project.",
+ "Use only the workspace prompt here and leave out the global prompt.",
+ "chat.projectPromptContentHint": "Leave empty to use global prompts only for this workspace.",
"chat.projectPromptPlaceholder":
- "Describe the context, constraints, and working style specific to this project...",
- "chat.workspaceSettingsTitle": "Project settings",
- "chat.workspaceSettingsNavigation": "Project settings navigation",
+ "Describe the context, constraints, and working style specific to this workspace...",
+ "chat.workspaceSettingsTitle": "Workspace settings",
+ "chat.workspaceSettingsNavigation": "Workspace settings navigation",
"chat.workspaceSettingsGeneral": "General",
"chat.workspaceSettingsDirectories": "Directories & permissions",
"chat.workspaceSettingsResources": "Resources",
- "chat.workspaceSettingsProjectName": "Project name",
- "chat.workspaceSettingsProjectNameRequired": "Project name is required.",
- "chat.workspaceSettingsProjectType": "Project type",
- "chat.workspaceSettingsKindManaged": "Managed project",
+ "chat.workspaceSettingsProjectName": "Workspace name",
+ "chat.workspaceSettingsProjectNameRequired": "Workspace name is required.",
+ "chat.workspaceSettingsProjectType": "Workspace type",
+ "chat.workspaceSettingsKindManaged": "Managed workspace",
"chat.workspaceSettingsKindFolder": "Local folder",
"chat.workspaceSettingsKindHistory": "History workspace",
"chat.workspaceSettingsPrimaryDirectory": "Primary directory",
@@ -246,9 +247,9 @@ export const EN_US_COMMON_TRANSLATIONS = {
"chat.workspaceSettingsDirectoryRead": "Read-only",
"chat.workspaceSettingsDirectoryWrite": "Read & write",
"chat.workspaceSettingsDirectoryDuplicate":
- "This directory has already been added to the project.",
+ "This directory has already been added to the workspace.",
"chat.workspaceSettingsResourcesDescription":
- "Control which Skills and MCP services this project exposes to the Agent.",
+ "Control which Skills and MCP services this workspace exposes to the Agent.",
"chat.workspaceRootGrantsRevokeFailed":
"Failed to revoke additional directory access; the workspace was not removed.",
"chat.workspaceResources": "Configure Skills & MCP",
@@ -432,7 +433,7 @@ export const EN_US_COMMON_TRANSLATIONS = {
"chat.upload.addSection": "Add",
"chat.upload.filesAndFolders": "Files & folders",
"chat.workspacePathDrop.reference": "Drop to reference this file or folder",
- "chat.workspacePathDrop.crossProject": "This path belongs to another project",
+ "chat.workspacePathDrop.crossProject": "This path belongs to another workspace",
"chat.upload.files": "Files",
"chat.upload.folder": "Folder",
"chat.upload.selectFolder": "Select a folder to mount as a project directory",
@@ -745,7 +746,13 @@ export const EN_US_COMMON_TRANSLATIONS = {
"sidebar.readingHistory": "Reading conversation history...",
"sidebar.autoShowLater": "Recent saved conversations will appear automatically.",
"sidebar.loadingMoreHistory": "Loading more history...",
- "sidebar.continueLoadingHistory": "Keep scrolling to load more",
+ "sidebar.continueLoadingHistory": "Load more conversations",
+ "chat.conversationArchive": "Archive conversation",
+ "chat.conversationRestore": "Restore conversation",
+ "chat.archivedConversations": "Archived conversations ({count})",
+ "chat.pinnedSection": "Pinned",
+ "chat.workspaceHistoryToggle": "Expand/collapse conversations",
+ "chat.workspaceHistoryRetry": "Retry loading conversations",
"sharedHistory.title": "Shared conversations",
"sharedHistory.managerLabel": "Manage shared conversations",
"sharedHistory.subtitle":
diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts
index 83b50e4bb..5c9f3712a 100644
--- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts
+++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts
@@ -136,7 +136,7 @@ export const ZH_CN_COMMON_TRANSLATIONS = {
"chat.workspaceDropTooLarge": "文件夹内容超过 {max} MiB,请精简后重试",
"chat.workspaceMountDropNoProject": "请先选择一个工作空间,再拖入文件夹作为附属目录",
"chat.workspaceMountDropSuccess":
- "已将 {count} 个文件夹挂载为附属目录(只读,可在项目设置中调整)",
+ "已将 {count} 个文件夹挂载为附属目录(只读,可在工作空间设置中调整)",
"chat.workspaceMountDropSkippedInside": "已跳过 {count} 个位于当前工作空间内的文件夹",
"chat.workspaceMountDropSkippedOverlap": "已跳过 {count} 个与现有附属目录重叠的文件夹",
"chat.workspaceMountDropFailed": "挂载附属目录失败",
@@ -148,7 +148,8 @@ export const ZH_CN_COMMON_TRANSLATIONS = {
"chat.workspaceGroupRename": "重命名分组",
"chat.workspaceGroupDelete": "删除分组",
"chat.workspaceGroupDeleteConfirmTitle": "删除分组「{name}」?",
- "chat.workspaceGroupDeleteConfirmDescription": "组内项目将回到未分组,项目本身不会被删除。",
+ "chat.workspaceGroupDeleteConfirmDescription":
+ "组内工作空间将回到未分组,工作空间本身不会被删除。",
"chat.workspaceGroupToggle": "展开/折叠分组",
"chat.workspaceGroupActions": "分组操作",
"chat.workspaceGroupMove": "移动到分组",
@@ -156,6 +157,7 @@ export const ZH_CN_COMMON_TRANSLATIONS = {
"chat.workspaceCreateDescription": "打开已有文件夹,或从远程 Git 仓库创建新的工作空间。",
"chat.workspaceOpenFolder": "打开本地文件夹",
"chat.workspaceOpenFolderDescription": "将已有文件夹添加为工作空间。",
+ "chat.workspaceOr": "或",
"chat.workspaceOpenFolderSubmit": "选择",
"chat.workspaceCloneRepository": "克隆 Git 仓库",
"chat.workspaceCloneDescription": "克隆完成后自动打开该仓库作为工作空间。",
@@ -185,25 +187,25 @@ export const ZH_CN_COMMON_TRANSLATIONS = {
"chat.workspaceNewConversation": "新对话",
"chat.workspaceMore": "更多",
"chat.workspaceConfigure": "配置",
- "chat.projectPromptTitle": "项目提示词",
+ "chat.projectPromptTitle": "工作空间提示词",
"chat.globalPromptTitle": "全局提示词",
"chat.projectPromptStrategy": "与全局提示词的关系",
- "chat.projectPromptStrategyHint": "选择项目提示词如何与当前启用的全局模板组合。",
+ "chat.projectPromptStrategyHint": "选择工作空间提示词如何与当前启用的全局模板组合。",
"chat.projectPromptAppend": "拼接",
- "chat.projectPromptAppendHint": "先应用当前全局提示词,再追加这个项目的专属要求。",
+ "chat.projectPromptAppendHint": "先应用当前全局提示词,再追加这个工作空间的专属要求。",
"chat.projectPromptReplace": "覆盖",
- "chat.projectPromptReplaceHint": "这个项目只使用项目提示词,不加载全局提示词。",
- "chat.projectPromptContentHint": "留空表示该项目仅使用全局提示词。",
- "chat.projectPromptPlaceholder": "输入这个项目专属的背景、约束和工作方式...",
- "chat.workspaceSettingsTitle": "项目配置",
- "chat.workspaceSettingsNavigation": "项目配置导航",
+ "chat.projectPromptReplaceHint": "这个工作空间只使用工作空间提示词,不加载全局提示词。",
+ "chat.projectPromptContentHint": "留空表示该工作空间仅使用全局提示词。",
+ "chat.projectPromptPlaceholder": "输入这个工作空间专属的背景、约束和工作方式...",
+ "chat.workspaceSettingsTitle": "工作空间配置",
+ "chat.workspaceSettingsNavigation": "工作空间配置导航",
"chat.workspaceSettingsGeneral": "通用配置",
"chat.workspaceSettingsDirectories": "目录与权限",
"chat.workspaceSettingsResources": "资源配置",
- "chat.workspaceSettingsProjectName": "项目名称",
- "chat.workspaceSettingsProjectNameRequired": "项目名称不能为空。",
- "chat.workspaceSettingsProjectType": "项目类型",
- "chat.workspaceSettingsKindManaged": "托管项目",
+ "chat.workspaceSettingsProjectName": "工作空间名称",
+ "chat.workspaceSettingsProjectNameRequired": "工作空间名称不能为空。",
+ "chat.workspaceSettingsProjectType": "工作空间类型",
+ "chat.workspaceSettingsKindManaged": "托管工作空间",
"chat.workspaceSettingsKindFolder": "本地文件夹",
"chat.workspaceSettingsKindHistory": "历史工作区",
"chat.workspaceSettingsPrimaryDirectory": "主目录",
@@ -226,9 +228,9 @@ export const ZH_CN_COMMON_TRANSLATIONS = {
"chat.workspaceSettingsDirectoryAccess": "访问权限",
"chat.workspaceSettingsDirectoryRead": "只读",
"chat.workspaceSettingsDirectoryWrite": "读写",
- "chat.workspaceSettingsDirectoryDuplicate": "该目录已经添加到当前项目。",
+ "chat.workspaceSettingsDirectoryDuplicate": "该目录已经添加到当前工作空间。",
"chat.workspaceSettingsResourcesDescription":
- "控制此项目会向 Agent 提供哪些 Skills 与 MCP 服务。",
+ "控制此工作空间会向 Agent 提供哪些 Skills 与 MCP 服务。",
"chat.workspaceRootGrantsRevokeFailed": "撤销附加目录授权失败,工作空间未移除。",
"chat.workspaceResources": "配置 Skills 与 MCP",
"chat.workspaceResourcesTitle": "工作空间资源设置",
@@ -397,7 +399,7 @@ export const ZH_CN_COMMON_TRANSLATIONS = {
"chat.upload.addSection": "添加",
"chat.upload.filesAndFolders": "文件和文件夹",
"chat.workspacePathDrop.reference": "释放以引用文件或文件夹",
- "chat.workspacePathDrop.crossProject": "不能引用其他项目中的路径",
+ "chat.workspacePathDrop.crossProject": "不能引用其他工作空间中的路径",
"chat.upload.files": "文件",
"chat.upload.folder": "文件夹",
"chat.upload.selectFolder": "选择一个文件夹并将其挂载为项目目录",
@@ -700,7 +702,13 @@ export const ZH_CN_COMMON_TRANSLATIONS = {
"sidebar.readingHistory": "正在读取历史记录...",
"sidebar.autoShowLater": "稍后会自动显示最近保存的对话。",
"sidebar.loadingMoreHistory": "正在加载更多历史记录...",
- "sidebar.continueLoadingHistory": "继续滚动加载更多",
+ "sidebar.continueLoadingHistory": "继续加载会话",
+ "chat.conversationArchive": "归档会话",
+ "chat.conversationRestore": "恢复会话",
+ "chat.archivedConversations": "已归档会话({count})",
+ "chat.pinnedSection": "置顶",
+ "chat.workspaceHistoryToggle": "展开/折叠会话",
+ "chat.workspaceHistoryRetry": "重试加载会话",
"sharedHistory.title": "已分享会话",
"sharedHistory.managerLabel": "管理已分享会话",
"sharedHistory.subtitle": "统一查看公开只读链接,复制访问地址,或关闭不再需要的分享。",
diff --git a/crates/agent-ui/src/lib/settings/index.ts b/crates/agent-ui/src/lib/settings/index.ts
index 61ff9d485..c5a116379 100644
--- a/crates/agent-ui/src/lib/settings/index.ts
+++ b/crates/agent-ui/src/lib/settings/index.ts
@@ -34,6 +34,7 @@ import {
MAX_CHAT_TRANSCRIPT_WIDTH,
MIN_CHAT_TRANSCRIPT_WIDTH,
} from "@liveagent/ui/lib/transcript-width/transcriptWidthModel";
+import { normalizeArchivedSidebarConversations } from "../sidebar/preferences";
import { normalizeModelFailoverSettings } from "./modelFailover";
import {
normalizeChatTranscriptSettings,
@@ -1423,6 +1424,11 @@ export function normalizeSystemSettings(input: unknown): SystemSettings {
browserAutomationMode: normalizeBrowserAutomationMode(obj.browserAutomationMode),
workspaceProjects: normalizeWorkspaceProjects(obj.workspaceProjects),
workspaceProjectGroups: normalizeWorkspaceProjectGroups(obj.workspaceProjectGroups),
+ workspaceProjectOrder: [
+ ...new Set(normalizeStringArray(obj.workspaceProjectOrder).map(workspaceProjectPathKey)),
+ ],
+ sidebarPinnedOrder: [...new Set(normalizeStringArray(obj.sidebarPinnedOrder))],
+ archivedConversations: normalizeArchivedSidebarConversations(obj.archivedConversations),
activeWorkspaceProjectId:
typeof obj.activeWorkspaceProjectId === "string" && obj.activeWorkspaceProjectId.trim()
? obj.activeWorkspaceProjectId.trim()
@@ -1657,6 +1663,9 @@ export function getDefaultSettings(): AppSettings {
hiddenWorkspaceProjectPaths: [],
missingWorkspaceProjectPaths: [],
archivedWorkspaceProjectPaths: [],
+ workspaceProjectOrder: [],
+ sidebarPinnedOrder: [],
+ archivedConversations: [],
workspaceResourceSettings: {},
systemProxy: getDefaultSystemProxyConfig(),
},
diff --git a/crates/agent-ui/src/lib/settings/types.ts b/crates/agent-ui/src/lib/settings/types.ts
index 458c1188c..539a6a9a5 100644
--- a/crates/agent-ui/src/lib/settings/types.ts
+++ b/crates/agent-ui/src/lib/settings/types.ts
@@ -351,6 +351,9 @@ export type SystemSettings = {
browserAutomationMode: BrowserAutomationMode;
workspaceProjects: WorkspaceProject[];
workspaceProjectGroups: WorkspaceProjectGroup[];
+ workspaceProjectOrder?: string[];
+ sidebarPinnedOrder?: string[];
+ archivedConversations?: { id: string; title: string; cwd?: string }[];
activeWorkspaceProjectId?: string;
hiddenWorkspaceProjectPaths: string[];
missingWorkspaceProjectPaths: string[];
diff --git a/crates/agent-ui/src/lib/sidebar/backend.ts b/crates/agent-ui/src/lib/sidebar/backend.ts
index 118545d76..d84f8c21d 100644
--- a/crates/agent-ui/src/lib/sidebar/backend.ts
+++ b/crates/agent-ui/src/lib/sidebar/backend.ts
@@ -16,6 +16,7 @@ export type SidebarListPage = {
};
export type SidebarBackend = {
+ listPinnedConversations?(): Promise
;
listConversations(page: number, pageSize: number, scope: SidebarScope): Promise;
listWorkdirs(): Promise;
renameConversation(id: string, title: string): Promise;
diff --git a/crates/agent-ui/src/lib/sidebar/batchDelete.ts b/crates/agent-ui/src/lib/sidebar/batchDelete.ts
index 01e4e0533..42771f9ff 100644
--- a/crates/agent-ui/src/lib/sidebar/batchDelete.ts
+++ b/crates/agent-ui/src/lib/sidebar/batchDelete.ts
@@ -1,3 +1,29 @@
+import type { ArchivedSidebarConversation } from "./preferences";
+import type { SidebarStore } from "./store";
+
+export async function deleteSidebarConversation(
+ id: string,
+ context: {
+ store: SidebarStore;
+ archivedConversations?: readonly ArchivedSidebarConversation[];
+ onSetConversationArchived?: (item: ArchivedSidebarConversation, archived: boolean) => void;
+ },
+): Promise {
+ const { store, archivedConversations, onSetConversationArchived } = context;
+ if (store.getSnapshot().mutations.has(id)) return false;
+ const archived = archivedConversations?.find((item) => item.id === id);
+ // After a restart, an archived row may exist only in settings. Seed its
+ // summary so the store can apply its normal running/error/rollback guards.
+ if (archived && !store.peek(id)) {
+ store.upsertLocal({ ...archived, providerId: "", model: "", createdAt: 0, updatedAt: 0 });
+ }
+ const removed = await store.remove(id);
+ // An optimistic removal can roll back. Forget the fallback row only after
+ // the backend has confirmed that the conversation was deleted.
+ if (removed && archived) onSetConversationArchived?.(archived, false);
+ return removed;
+}
+
export type SidebarBatchDeleteResult = {
deletedIds: readonly string[];
failedIds: readonly string[];
diff --git a/crates/agent-ui/src/lib/sidebar/pinnedHistory.ts b/crates/agent-ui/src/lib/sidebar/pinnedHistory.ts
new file mode 100644
index 000000000..68e51706e
--- /dev/null
+++ b/crates/agent-ui/src/lib/sidebar/pinnedHistory.ts
@@ -0,0 +1,19 @@
+import type { SidebarListPage } from "./backend";
+import type { SidebarConversation } from "./types";
+
+// History endpoints order pinned rows first. Stop as soon as the first
+// ordinary row arrives, without scanning every project's history.
+export async function listPinnedSidebarConversations(
+ listPage: (page: number, pageSize: number) => Promise,
+): Promise {
+ const result = new Map();
+ for (let pageNumber = 1; ; pageNumber++) {
+ const page = await listPage(pageNumber, 200);
+ for (const item of page.items) {
+ if (!item.isPinned) return Array.from(result.values());
+ result.set(item.id, item);
+ }
+ if (page.items.length < 200 || pageNumber * 200 >= page.totalCount)
+ return Array.from(result.values());
+ }
+}
diff --git a/crates/agent-ui/src/lib/sidebar/preferences.ts b/crates/agent-ui/src/lib/sidebar/preferences.ts
new file mode 100644
index 000000000..f88846ee9
--- /dev/null
+++ b/crates/agent-ui/src/lib/sidebar/preferences.ts
@@ -0,0 +1,105 @@
+import type { AppSettings, WorkspaceProject } from "../settings/types";
+import { workspaceProjectPathKey } from "../settings/workspaceProjects";
+import type { SidebarConversation } from "./types";
+
+export type ArchivedSidebarConversation = Pick;
+
+export function normalizeArchivedSidebarConversations(
+ input: unknown,
+): ArchivedSidebarConversation[] {
+ if (!Array.isArray(input)) return [];
+ const entries = new Map();
+ for (const item of input) {
+ if (!item || typeof item !== "object" || typeof item.id !== "string" || !item.id.trim())
+ continue;
+ entries.set(item.id.trim(), {
+ id: item.id.trim(),
+ title:
+ typeof item.title === "string" && item.title.trim() ? item.title.trim() : item.id.trim(),
+ cwd: typeof item.cwd === "string" ? item.cwd.trim() : undefined,
+ });
+ }
+ return Array.from(entries.values());
+}
+
+export function setSidebarConversationArchived(
+ settings: AppSettings,
+ item: ArchivedSidebarConversation,
+ archived: boolean,
+): AppSettings {
+ const entries = (settings.system.archivedConversations ?? []).filter(
+ (entry) => entry.id !== item.id,
+ );
+ if (archived) entries.push({ id: item.id, title: item.title, cwd: item.cwd });
+ return { ...settings, system: { ...settings.system, archivedConversations: entries } };
+}
+
+// Reordering never changes a project's pinned status or group membership.
+export function reorderSidebarProjects(
+ projects: readonly WorkspaceProject[],
+ sourceId: string,
+ targetId: string,
+ position: "before" | "after",
+): string[] | null {
+ const source = projects.find((project) => project.id === sourceId);
+ const target = projects.find((project) => project.id === targetId);
+ if (
+ !source ||
+ !target ||
+ sourceId === targetId ||
+ Boolean(source.isPinned) !== Boolean(target.isPinned)
+ )
+ return null;
+ const next = projects.filter((project) => project.id !== sourceId);
+ const index = next.findIndex((project) => project.id === targetId);
+ next.splice(index + (position === "after" ? 1 : 0), 0, source);
+ return next.map((project) => workspaceProjectPathKey(project.path));
+}
+
+export type SidebarPinnedEntry =
+ | { key: string; kind: "conversation"; item: SidebarConversation }
+ | { key: string; kind: "workspace"; project: WorkspaceProject };
+
+export const sidebarConversationOrderKey = (id: string) => `conversation:${id}`;
+export const sidebarWorkspaceOrderKey = (path: string) =>
+ `workspace:${workspaceProjectPathKey(path)}`;
+
+export function buildSidebarPinnedEntries(
+ conversations: readonly SidebarConversation[],
+ projects: readonly WorkspaceProject[],
+ order: readonly string[] = [],
+): SidebarPinnedEntry[] {
+ const entries: SidebarPinnedEntry[] = [
+ ...conversations.map(
+ (item): SidebarPinnedEntry => ({
+ key: sidebarConversationOrderKey(item.id),
+ kind: "conversation",
+ item,
+ }),
+ ),
+ ...projects.map(
+ (project): SidebarPinnedEntry => ({
+ key: sidebarWorkspaceOrderKey(project.path),
+ kind: "workspace",
+ project,
+ }),
+ ),
+ ];
+ const ranks = new Map(order.map((key, index) => [key, index]));
+ // Newly pinned entries appear first; existing entries keep their saved order.
+ return entries.sort((a, b) => (ranks.get(a.key) ?? -1) - (ranks.get(b.key) ?? -1));
+}
+
+export function reorderSidebarPinnedEntries(
+ entries: readonly SidebarPinnedEntry[],
+ source: string,
+ target: string,
+ position: "before" | "after",
+): string[] | null {
+ const keys = entries.map((entry) => entry.key);
+ if (source === target || !keys.includes(source) || !keys.includes(target)) return null;
+ const next = keys.filter((key) => key !== source);
+ const index = next.indexOf(target);
+ next.splice(index + (position === "after" ? 1 : 0), 0, source);
+ return next;
+}
diff --git a/crates/agent-ui/src/lib/sidebar/store.ts b/crates/agent-ui/src/lib/sidebar/store.ts
index 101d96ba4..714338b78 100644
--- a/crates/agent-ui/src/lib/sidebar/store.ts
+++ b/crates/agent-ui/src/lib/sidebar/store.ts
@@ -39,7 +39,22 @@ const DEFAULT_WORKDIRS_FALLBACK_MS = 300_000;
const DEFAULT_WORKDIRS_DEBOUNCE_MS = 2_000;
const DEFAULT_POSITION_LOCK_MS = 1_200;
+export const WORKSPACE_HISTORY_PAGE_SIZE = 10;
+
+export type WorkspaceHistoryState = {
+ cwd: string;
+ limit: number;
+ requestedLimit: number;
+ hasMore: boolean;
+ excludedIds: ReadonlySet;
+ totalCount: number;
+ loading: boolean;
+ loaded: boolean;
+ error: string | null;
+};
+
export type SidebarSnapshot = {
+ workspaceHistory: ReadonlyMap;
revision: number;
scopeKey: string;
conversations: readonly SidebarConversation[];
@@ -75,6 +90,11 @@ export type SidebarStore = {
setScope(scope: SidebarScope): void;
refresh(options?: { reason?: SidebarRefreshReason }): Promise;
loadMore(): Promise;
+ loadWorkspaceHistory(
+ cwd: string,
+ more?: boolean,
+ excludedIds?: ReadonlySet,
+ ): Promise;
refreshWorkdirs(reason: SidebarWorkdirsRefreshReason): Promise;
rename(id: string, title: string): Promise;
setPinned(id: string, isPinned: boolean): Promise;
@@ -128,6 +148,7 @@ export function createSidebarStore(
const runningStatusUpdatedAt = new Map();
const positionLocks = new Map();
let snapshot: SidebarSnapshot = {
+ workspaceHistory: new Map(),
revision: 0,
scopeKey: sidebarScopeKey(scope),
conversations: [],
@@ -175,6 +196,179 @@ export function createSidebarStore(
}
};
+ // Each expanded project owns its limit and request. Fetch the requested
+ // prefix so pinning/deletion between clicks cannot shift an offset and skip rows.
+ let workspaceGeneration = 0;
+ let workspaceDeleteVersion = 0;
+ const workspaceDeletedIds = new Map();
+ const loadWorkspaceHistory = async (
+ cwd: string,
+ more = false,
+ excludedIds: ReadonlySet = new Set(),
+ ) => {
+ const key = workspaceProjectPathKey(cwd);
+ if (!key) return;
+ const previous = snapshot.workspaceHistory.get(key);
+ if (previous?.loading) return;
+ const limit = previous?.error
+ ? previous.requestedLimit
+ : (previous?.limit ?? WORKSPACE_HISTORY_PAGE_SIZE) +
+ (more && previous?.loaded ? WORKSPACE_HISTORY_PAGE_SIZE : 0);
+ const deleteVersion = workspaceDeleteVersion;
+ const generation = workspaceGeneration;
+ const before = byId;
+ const loading = {
+ cwd,
+ limit: previous?.limit ?? limit,
+ requestedLimit: limit,
+ hasMore: previous?.hasMore ?? false,
+ excludedIds,
+ totalCount: previous?.totalCount ?? 0,
+ loaded: previous?.loaded ?? false,
+ loading: true,
+ error: null,
+ };
+ // Child effects may ask for a project before the parent starts the store.
+ // Remember that request; start() will load it once the transport is owned.
+ if (startCount === 0) {
+ commit({
+ workspaceHistory: new Map(snapshot.workspaceHistory).set(key, {
+ ...loading,
+ loading: false,
+ }),
+ });
+ return;
+ }
+ commit({ workspaceHistory: new Map(snapshot.workspaceHistory).set(key, loading) });
+ try {
+ // The gateway caps individual requests at 200. Read additional pages
+ // for larger expanded lists instead of getting stuck at that cap.
+ const excludedCount = Array.from(byId.values()).filter(
+ (item) =>
+ workspaceProjectPathKey(item.cwd ?? "") === key &&
+ (item.isPinned || excludedIds.has(item.id)),
+ ).length;
+ const batchSize = Math.min(limit + excludedCount, 200);
+ const page = await backend.listConversations(1, batchSize, { kind: "workdir", cwd });
+ let pageNumber = 1;
+ while (
+ page.items.filter((item) => !item.isPinned && !excludedIds.has(item.id)).length < limit &&
+ page.items.length < page.totalCount
+ ) {
+ if (generation !== workspaceGeneration) return;
+ const next = await backend.listConversations(++pageNumber, batchSize, {
+ kind: "workdir",
+ cwd,
+ });
+ page.totalCount = next.totalCount;
+ if (next.items.length === 0) break;
+ page.items.push(...next.items);
+ }
+ if (generation !== workspaceGeneration) return;
+ const incomingIds = new Set(page.items.map((item) => item.id));
+ byId = new Map(byId);
+ for (const incoming of page.items) {
+ // A concurrent event/mutation owns newer data, including deletion.
+ if (
+ (workspaceDeletedIds.get(incoming.id) ?? 0) > deleteVersion ||
+ byId.get(incoming.id) !== before.get(incoming.id) ||
+ snapshot.mutations.has(incoming.id)
+ )
+ continue;
+ byId.set(incoming.id, mergeSidebarConversation(byId.get(incoming.id), incoming));
+ }
+ if (page.items.length >= page.totalCount) {
+ for (const [id, item] of before) {
+ if (
+ workspaceProjectPathKey(item.cwd ?? "") === key &&
+ !incomingIds.has(id) &&
+ byId.get(id) === item &&
+ !item.isPending &&
+ !snapshot.mutations.has(id)
+ )
+ byId.delete(id);
+ }
+ }
+ commit({
+ byId,
+ workspaceHistory: new Map(snapshot.workspaceHistory).set(key, {
+ cwd,
+ limit,
+ requestedLimit: limit,
+ excludedIds,
+ hasMore: page.items.length < page.totalCount,
+ totalCount: page.totalCount,
+ loaded: true,
+ loading: false,
+ error: null,
+ }),
+ });
+ } catch (error) {
+ if (generation !== workspaceGeneration) return;
+ commit({
+ workspaceHistory: new Map(snapshot.workspaceHistory).set(key, {
+ ...loading,
+ loading: false,
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ });
+ }
+ };
+ let pinnedRequest: symbol | null = null;
+ const refreshPinnedHistory = async () => {
+ if (!backend.listPinnedConversations || startCount === 0 || pinnedRequest) return;
+ const token = Symbol();
+ pinnedRequest = token;
+ const generation = workspaceGeneration;
+ const deleteVersion = workspaceDeleteVersion;
+ const before = byId;
+ try {
+ const pinned = await backend.listPinnedConversations();
+ if (generation !== workspaceGeneration) return;
+ const ids = new Set(pinned.map((item) => item.id));
+ byId = new Map(byId);
+ for (const item of pinned) {
+ if (
+ byId.get(item.id) !== before.get(item.id) ||
+ snapshot.mutations.has(item.id) ||
+ (workspaceDeletedIds.get(item.id) ?? 0) > deleteVersion
+ )
+ continue;
+ byId.set(item.id, mergeSidebarConversation(byId.get(item.id), item));
+ }
+ for (const [id, item] of before) {
+ if (item.isPinned && !ids.has(id) && byId.get(id) === item && !snapshot.mutations.has(id))
+ byId.set(id, { ...item, isPinned: false, pinnedAt: null });
+ }
+ commit({ byId });
+ } catch (error) {
+ if (generation === workspaceGeneration)
+ commit({
+ listError: "listFailed",
+ listErrorDetail: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ if (pinnedRequest === token) pinnedRequest = null;
+ }
+ };
+ const invalidateWorkspaceRequests = () => {
+ workspaceGeneration += 1;
+ pinnedRequest = null;
+ commit({
+ workspaceHistory: new Map(
+ Array.from(snapshot.workspaceHistory, ([key, value]) => [
+ key,
+ { ...value, loading: false },
+ ]),
+ ),
+ });
+ };
+ const refreshWorkspaceHistory = () => {
+ void refreshPinnedHistory();
+ for (const state of snapshot.workspaceHistory.values())
+ void loadWorkspaceHistory(state.cwd, false, state.excludedIds);
+ };
+
const activePositionLockIds = () => {
const nowMs = now();
const ids: string[] = [];
@@ -307,6 +501,7 @@ export function createSidebarStore(
return;
}
case "delete": {
+ workspaceDeletedIds.set(event.conversationId, ++workspaceDeleteVersion);
if (byId.has(event.conversationId)) {
byId = new Map(byId);
byId.delete(event.conversationId);
@@ -686,6 +881,7 @@ export function createSidebarStore(
unsubscribeConnection =
backend.subscribeConnection?.((connected) => {
if (!connected) {
+ invalidateWorkspaceRequests();
wasDisconnected = true;
return;
}
@@ -693,14 +889,17 @@ export function createSidebarStore(
wasDisconnected = false;
void fetchFirstPage(false);
void refreshWorkdirs("reconnect");
+ refreshWorkspaceHistory();
}
}) ?? null;
reconcileTimer = setInterval(() => {
+ refreshWorkspaceHistory();
void fetchFirstPage(false);
}, reconcileIntervalMs);
workdirsFallbackTimer = setInterval(() => {
void refreshWorkdirs("fallback");
}, workdirsFallbackMs);
+ refreshWorkspaceHistory();
void refreshWorkdirs("initial");
void fetchFirstPage(true);
},
@@ -713,6 +912,7 @@ export function createSidebarStore(
if (startCount > 0) {
return;
}
+ invalidateWorkspaceRequests();
requestSeq += 1;
listGeneration += 1;
unsubscribeEvents?.();
@@ -766,6 +966,7 @@ export function createSidebarStore(
refresh,
loadMore,
+ loadWorkspaceHistory,
refreshWorkdirs,
rename: (id, title) =>
@@ -862,6 +1063,7 @@ export function createSidebarStore(
},
removeLocal: (conversationId) => {
+ workspaceDeletedIds.set(conversationId, ++workspaceDeleteVersion);
if (!byId.has(conversationId)) {
return;
}
diff --git a/crates/agent-ui/src/lib/sidebar/useSidebarContainerState.ts b/crates/agent-ui/src/lib/sidebar/useSidebarContainerState.ts
index c5931ff9a..bd1d7f180 100644
--- a/crates/agent-ui/src/lib/sidebar/useSidebarContainerState.ts
+++ b/crates/agent-ui/src/lib/sidebar/useSidebarContainerState.ts
@@ -7,6 +7,8 @@ import {
} from "@liveagent/ui/lib/sidebar/selectors";
import type { SidebarSnapshot, SidebarStore } from "@liveagent/ui/lib/sidebar/store";
import { useSidebarSelector } from "@liveagent/ui/lib/sidebar/useSidebarSelector";
+import { useMemo } from "react";
+import { sortSidebarConversations } from "./reconcile";
function selectMutations(snapshot: SidebarSnapshot) {
return snapshot.mutations;
@@ -16,8 +18,14 @@ function selectMutationErrors(snapshot: SidebarSnapshot) {
return snapshot.mutationErrors;
}
-export function useSidebarContainerState(store: SidebarStore) {
- const items = useSidebarSelector(store, selectConversations);
+export function useSidebarContainerState(store: SidebarStore, showProjects = false) {
+ const scopedItems = useSidebarSelector(store, selectConversations);
+ const byId = useSidebarSelector(store, (snapshot) => snapshot.byId);
+ const workspaceHistory = useSidebarSelector(store, (snapshot) => snapshot.workspaceHistory);
+ const items = useMemo(
+ () => (showProjects ? sortSidebarConversations(Array.from(byId.values())) : scopedItems),
+ [byId, scopedItems, showProjects],
+ );
const listState = useSidebarSelector(store, selectListState, sidebarShallowEqual);
const scopeKey = useSidebarSelector(store, (snapshot) => snapshot.scopeKey);
const runningConversationIds = useSidebarSelector(store, selectRunningConversationIds);
@@ -31,6 +39,7 @@ export function useSidebarContainerState(store: SidebarStore) {
return {
items,
+ workspaceHistory,
listState,
scopeKey,
runningConversationIds,
diff --git a/crates/agent-ui/src/lib/sidebar/useSidebarReorderDrag.ts b/crates/agent-ui/src/lib/sidebar/useSidebarReorderDrag.ts
new file mode 100644
index 000000000..451215007
--- /dev/null
+++ b/crates/agent-ui/src/lib/sidebar/useSidebarReorderDrag.ts
@@ -0,0 +1,161 @@
+import { type RefObject, useCallback, useEffect, useRef, useState } from "react";
+
+export type SidebarReorderPosition = "before" | "after";
+export type SidebarReorderPointer = {
+ pointerId: number;
+ clientX: number;
+ clientY: number;
+ currentTarget: HTMLElement;
+};
+
+/** Pointer sorting avoids WKWebView's native file-drop interception. */
+export function useSidebarReorderDrag(params: {
+ containerRef: RefObject;
+ disabled: boolean;
+ scopeKey: string;
+ canDrop: (source: string, target: string) => boolean;
+ onDrop: (source: string, target: string, position: SidebarReorderPosition) => void;
+}) {
+ const callbacksRef = useRef(params);
+ callbacksRef.current = params;
+ const cleanupRef = useRef<(() => void) | null>(null);
+ const suppressClickRef = useRef(false);
+ const ghostElementRef = useRef(null);
+ const ghostPositionRef = useRef({ x: 0, y: 0 });
+ const positionGhost = useCallback((x: number, y: number) => {
+ ghostPositionRef.current = { x, y };
+ ghostElementRef.current?.style.setProperty("--sidebar-drag-x", `${x + 12}px`);
+ ghostElementRef.current?.style.setProperty("--sidebar-drag-y", `${y - 34}px`);
+ }, []);
+ const ghostRef = useCallback(
+ (element: HTMLDivElement | null) => {
+ ghostElementRef.current = element;
+ positionGhost(ghostPositionRef.current.x, ghostPositionRef.current.y);
+ },
+ [positionGhost],
+ );
+ const [draggingKey, setDraggingKey] = useState(null);
+ const [dropTarget, setDropTarget] = useState<{
+ key: string;
+ position: SidebarReorderPosition;
+ } | null>(null);
+
+ const cancel = useCallback(() => {
+ cleanupRef.current?.();
+ cleanupRef.current = null;
+ setDraggingKey(null);
+ setDropTarget(null);
+ }, []);
+
+ // A changed scope invalidates the source and targets of the in-flight sort.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: scope changes must cancel the pointer session.
+ useEffect(() => {
+ cancel();
+ return cancel;
+ }, [cancel, params.disabled, params.scopeKey]);
+
+ const onPointerDown = useCallback(
+ (sourceKey: string, event: SidebarReorderPointer) => {
+ if (callbacksRef.current.disabled) return;
+ cancel();
+ suppressClickRef.current = false;
+ let active = false;
+ let target: { key: string; position: SidebarReorderPosition } | null = null;
+ const origin = event.currentTarget;
+ const pointerId = event.pointerId;
+ const previousCursor = document.body.style.cursor;
+ const previousUserSelect = document.body.style.userSelect;
+ const updateTarget = (x: number, y: number) => {
+ const container = callbacksRef.current.containerRef.current;
+ const element = document
+ .elementFromPoint(x, y)
+ ?.closest("[data-sidebar-reorder-key]");
+ const key = element?.dataset.sidebarReorderKey;
+ if (
+ !element ||
+ !key ||
+ !container?.contains(element) ||
+ !callbacksRef.current.canDrop(sourceKey, key)
+ ) {
+ target = null;
+ } else {
+ const rect = element.getBoundingClientRect();
+ target = { key, position: y < rect.top + rect.height / 2 ? "before" : "after" };
+ }
+ setDropTarget(target);
+ if (container) {
+ const rect = container.getBoundingClientRect();
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) {
+ const delta = y < rect.top + 28 ? -14 : y > rect.bottom - 28 ? 14 : 0;
+ if (delta) container.scrollBy({ top: delta });
+ }
+ }
+ };
+ const handleMove = (move: PointerEvent) => {
+ if (move.pointerId !== pointerId) return;
+ if (!active && Math.hypot(move.clientX - event.clientX, move.clientY - event.clientY) < 5)
+ return;
+ if (!active) {
+ active = true;
+ suppressClickRef.current = true;
+ setDraggingKey(sourceKey);
+ document.body.style.cursor = "grabbing";
+ document.body.style.userSelect = "none";
+ origin.setPointerCapture?.(pointerId);
+ }
+ move.preventDefault();
+ positionGhost(move.clientX, move.clientY);
+ updateTarget(move.clientX, move.clientY);
+ };
+ const handleUp = (up: PointerEvent) => {
+ if (up.pointerId !== pointerId) return;
+ if (active) {
+ up.preventDefault();
+ updateTarget(up.clientX, up.clientY);
+ if (target) callbacksRef.current.onDrop(sourceKey, target.key, target.position);
+ }
+ cancel();
+ };
+ const handleCancel = (event: PointerEvent) => {
+ if (event.pointerId === pointerId) cancel();
+ };
+ const handleKey = (event: KeyboardEvent) => {
+ if (event.key === "Escape") cancel();
+ };
+ cleanupRef.current = () => {
+ window.removeEventListener("pointermove", handleMove);
+ window.removeEventListener("pointerup", handleUp);
+ window.removeEventListener("pointercancel", handleCancel);
+ window.removeEventListener("blur", cancel);
+ window.removeEventListener("keydown", handleKey);
+ if (origin.hasPointerCapture?.(pointerId)) origin.releasePointerCapture(pointerId);
+ if (active) {
+ document.body.style.cursor = previousCursor;
+ document.body.style.userSelect = previousUserSelect;
+ }
+ };
+ window.addEventListener("pointermove", handleMove, { passive: false });
+ window.addEventListener("pointerup", handleUp);
+ window.addEventListener("pointercancel", handleCancel);
+ window.addEventListener("blur", cancel);
+ window.addEventListener("keydown", handleKey);
+ },
+ [cancel, positionGhost],
+ );
+
+ return {
+ ghostRef,
+ draggingKey,
+ dropTarget,
+ onPointerDown,
+ onPointerDownCapture: () => {
+ if (!cleanupRef.current) suppressClickRef.current = false;
+ },
+ onClickCapture: (event: React.MouseEvent) => {
+ if (!suppressClickRef.current) return;
+ suppressClickRef.current = false;
+ event.preventDefault();
+ event.stopPropagation();
+ },
+ };
+}
diff --git a/crates/agent-ui/src/lib/workspaceProjects.ts b/crates/agent-ui/src/lib/workspaceProjects.ts
index fab6b87a3..1b004f609 100644
--- a/crates/agent-ui/src/lib/workspaceProjects.ts
+++ b/crates/agent-ui/src/lib/workspaceProjects.ts
@@ -297,6 +297,7 @@ function trimWorkspaceProjectActivityUpdatedAts(
export function sortWorkspaceProjectsByActivity(
projects: readonly WorkspaceProject[],
options?: {
+ projectOrder?: readonly string[];
projectActivityUpdatedAts?: ReadonlyMap;
runningProjectPathKeys?: ReadonlySet;
},
@@ -305,6 +306,9 @@ export function sortWorkspaceProjectsByActivity(
return [...projects];
}
+ const manualOrder = new Map(
+ options?.projectOrder?.map((path, index) => [workspaceProjectPathKey(path), index]),
+ );
const projectActivityUpdatedAts =
options?.projectActivityUpdatedAts ?? EMPTY_PROJECT_ACTIVITY_UPDATED_ATS;
const runningProjectPathKeys = options?.runningProjectPathKeys ?? EMPTY_RUNNING_PROJECT_PATH_KEYS;
@@ -330,6 +334,12 @@ export function sortWorkspaceProjectsByActivity(
if (leftIsPinned !== rightIsPinned) {
return leftIsPinned ? -1 : 1;
}
+ if (manualOrder.size > 0) {
+ const orderDelta =
+ (manualOrder.get(left.pathKey) ?? Number.MAX_SAFE_INTEGER) -
+ (manualOrder.get(right.pathKey) ?? Number.MAX_SAFE_INTEGER);
+ if (orderDelta !== 0) return orderDelta;
+ }
if (leftIsPinned && rightIsPinned) {
const pinnedDelta = readProjectPinnedAt(right.project) - readProjectPinnedAt(left.project);
if (pinnedDelta !== 0) {
diff --git a/docs/features/history-compaction.md b/docs/features/history-compaction.md
index 9ec9d62bc..35e69cd28 100644
--- a/docs/features/history-compaction.md
+++ b/docs/features/history-compaction.md
@@ -83,6 +83,16 @@ Summary 的 `` 段由模型生成,会漏、会幻觉。为此每个
GUI/WebUI 的 sidebar 都依赖 summary 中的 pin/share 字段,因此新增历史字段时必须同步 Rust summary、proto、Gateway payload 和两端 UI。
+### 工作空间会话树
+
+Desktop 与 WebUI 共用工作空间侧栏。置顶内容与普通工作空间分成两个独立折叠的区域;置顶会话只在置顶区域出现,置顶工作空间可继续展开其会话。点击工作空间标题即可展开或收起,每次默认显示 10 条会话,通过“继续加载会话”追加。全局搜索命中的会话会自动展开并定位,即使它超出当前会话页或工作空间折叠数量上限。
+
+鼠标拖动工作空间可以调整同组内顺序;置顶会话与置顶工作空间支持混合排序。拖动时显示蓝色插入线和标题预览,按 Escape 可取消,按住 Alt 拖动保留工作台拖拽行为。顺序保存在 `system.workspaceProjectOrder` 与 `system.sidebarPinnedOrder`,重启后继续使用;排序不改变置顶状态或分组归属。
+
+会话行悬停时提供置顶、归档操作,归档会话可在侧栏的归档列表恢复。归档只保存 `system.archivedConversations` 元数据,不删除历史消息;运行中的会话不能归档。工作空间行悬停时提供新建会话与更多菜单。“选择多个”按钮再次点击即可退出多选。
+
+Desktop 支持将外部文件夹或文件树中的目录拖入工作空间区域,将其添加为工作空间。工作空间排序不会触发文件夹导入提示。新建工作空间弹框保留直接打开文件夹与完整 Git 克隆表单,Desktop 与 WebUI 使用同一实现。
+
## WebUI 大历史优化
| 优化 | 说明 |