mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
feat: support renaming imported workspaces (#1179)
This commit is contained in:
+1
-1
@@ -106,7 +106,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています
|
||||
モダンなチャット体験を通じてAIエージェントとコミュニケーションできます。複数の会話コンテキスト、メッセージ履歴、Markdownによるリッチコンテンツレンダリング(GitHub 風テーブルや KaTeX による LaTeX 数式 `$インライン$`、`$$ブロック$$`、`\(インライン\)`、`\[ブロック\]` を含む)に加え、マルチエージェント構成ではメイン入力欄の `@agent` から対象エージェントへ直接ルーティングできます。
|
||||
コンポーザーから挿入した Skill は `/skill-name` 形式のチップとして表示され、チップをクリックすると右側のプレビュー側欄でその Skill の `SKILL.md` を開けます。
|
||||
`@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。
|
||||
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができ、行にはホバーで操作ボタンが出るまで相対アクティビティ時刻が表示されます。編集可能なチャットでは、コンポーザーのワークスペースチップから既定ワークスペースへ戻すか別フォルダーを選ぶ小さなメニューを開けます。
|
||||
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができ、行にはホバーで操作ボタンが出るまで相対アクティビティ時刻が表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。編集可能なチャットでは、コンポーザーのワークスペースチップから既定ワークスペースへ戻すか別フォルダーを選ぶ小さなメニューを開けます。
|
||||
各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。
|
||||
|
||||
### 📡 マルチチャネル管理
|
||||
|
||||
@@ -106,7 +106,7 @@ Complete the entire setup—from installation to your first AI interaction—thr
|
||||
Communicate with AI agents through a modern chat experience. Support for multiple conversation contexts, message history, rich content rendering with Markdown (including GitHub-flavored tables and KaTeX-powered LaTeX math: `$inline$`, `$$block$$`, `\(inline\)`, and `\[block\]`), and direct `@agent` routing in the main composer for multi-agent setups.
|
||||
Skills you insert from the composer appear as `/skill-name` chips; click a chip to open the preview sidebar and read that skill's `SKILL.md`.
|
||||
When you target another agent with `@agent`, ClawX switches into that agent's own conversation context directly instead of relaying through the default agent. Agent workspaces stay separate by default, and stronger isolation depends on OpenClaw sandbox settings.
|
||||
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, each workspace can collapse or load more sessions, and rows show relative activity until hover reveals actions. Editable chats expose the composer workspace chip as a small menu for returning to the default workspace or choosing another folder.
|
||||
The session sidebar is workspace-first: the default workspace stays at the top, other workspaces sort naturally, each workspace can collapse or load more sessions, and rows show relative activity until hover reveals actions. Imported workspaces can be renamed from their sidebar header; the custom name is reflected in the chat composer while hovering the header still reveals the filesystem path. Editable chats expose the composer workspace chip as a small menu for returning to the default workspace or choosing another folder.
|
||||
Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model.
|
||||
|
||||
### 📡 Multi-Channel Management
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们
|
||||
通过现代化的聊天体验与 AI 智能体交互。支持多会话上下文、消息历史记录、Markdown 富文本渲染(包括 GitHub 风格表格以及由 KaTeX 渲染的 LaTeX 数学公式:`$行内$`、`$$块级$$`、`\(行内\)` 和 `\[块级\]`),以及在多 Agent 场景下通过主输入框中的 `@agent` 直接路由到目标智能体。
|
||||
从输入框插入的技能会以 `/技能名` 卡片形式显示;点击卡片可在右侧预览栏打开并阅读该技能的 `SKILL.md`。
|
||||
当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。
|
||||
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话,行内会显示相对活跃时间直到悬停时露出操作按钮。可编辑的新对话中,输入框的工作空间卡片会打开一个小菜单,可切回默认工作空间或选择其它目录。
|
||||
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话,行内会显示相对活跃时间直到悬停时露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。可编辑的新对话中,输入框的工作空间卡片会打开一个小菜单,可切回默认工作空间或选择其它目录。
|
||||
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
|
||||
|
||||
### 📡 多频道管理
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface AppSettings {
|
||||
devModeUnlocked: boolean;
|
||||
chatWorkspacePath: string;
|
||||
recentWorkspacePaths: string[];
|
||||
workspaceLabels: Record<string, string>;
|
||||
|
||||
// Presets
|
||||
selectedBundles: string[];
|
||||
@@ -107,6 +108,7 @@ function createDefaultSettings(): AppSettings {
|
||||
devModeUnlocked: false,
|
||||
chatWorkspacePath: DEFAULT_WORKSPACE_CWD,
|
||||
recentWorkspacePaths: [DEFAULT_WORKSPACE_CWD],
|
||||
workspaceLabels: {},
|
||||
|
||||
// Presets
|
||||
selectedBundles: ['productivity', 'developer'],
|
||||
|
||||
@@ -18,7 +18,7 @@ OpenClaw's persisted ACP `cwd` is authoritative for a bound session. The global
|
||||
|
||||
The effective workspace is shared by ACP load/prompt, the composer, sidebar grouping, the right-side workspace browser, and tool-derived file activity. A bound session is read-only in the composer and is not moved when the global selection changes. Missing or unreadable bound paths show unavailable/error state instead of silently changing roots.
|
||||
|
||||
ClawX persists global and recent workspace selections through Main-owned settings APIs. Renderer session state may mirror the bound path for UI coordination, but must not become a competing persistent session-to-path authority. Targeted `@agent` sends may intentionally use the target agent workspace and should remain an explicit branch.
|
||||
ClawX persists global and recent workspace selections plus custom display labels through Main-owned settings APIs. Custom labels are keyed by canonical path and never replace path identity or ACP cwd authority. Renderer session state may mirror the bound path for UI coordination, but must not become a competing persistent session-to-path authority. Targeted `@agent` sends may intentionally use the target agent workspace and should remain an explicit branch.
|
||||
|
||||
## First Send And Titles
|
||||
|
||||
@@ -37,6 +37,8 @@ Sessions are grouped by workspace, not by date bucket. The default workspace sor
|
||||
|
||||
Each group initially displays five sessions and loads five more at a time. Collapse and visible-count state are per workspace and in memory. Relative time and ordering use the same timestamp; actions replace the timestamp on hover or keyboard focus.
|
||||
|
||||
Non-default workspace headers expose a rename action on hover or keyboard focus. A custom name updates both the sidebar group and the composer workspace chip; the header and chip keep the full filesystem path in their title text.
|
||||
|
||||
## Workspace Browser
|
||||
|
||||
The right panel tabs remain Workspace, Preview, and Changes. The Workspace tree uses `react-arborist`, includes hidden files, uses relative path as node identity, and remains read-only: no edit, drag/drop, or multi-select. Agent and path tags replace the older `Workspace - agent` header. Home is compacted to `~`, the path's final segment remains visible, and the full value is available as a title.
|
||||
|
||||
@@ -10,6 +10,8 @@ appliesTo:
|
||||
|
||||
OpenClaw ACP cwd is authoritative for a bound Chat session. Global workspace selection applies only to new or unbound sessions, and consumers use one effective workspace for ACP load/prompt, composer state, sidebar grouping, workspace browsing, and file activity. Missing paths surface unavailable state instead of silently changing roots.
|
||||
|
||||
Custom workspace names are display-only aliases keyed by canonical workspace path. They may change sidebar and composer labels, but never path-based grouping, ACP cwd, browser roots, attachment authority, or session binding.
|
||||
|
||||
The ACP load or new-session operation is the only boundary that establishes session workspace context. Main canonicalizes the workspace root and execution cwd, registers them only after a successful load, restores the prior context after failure, and validates later attachment operations by exact session key and generation. Attachment resolve, read, preview, and open requests cannot provide or replace the execution cwd and must be revalidated in Main on every operation. Local attachment references may resolve outside the workspace; the workspace remains authoritative for relative-path resolution and the separate workspace browser and tool-derived file boundaries. Session or generation replacement revokes the prior context; attachment refs and prior resolution are not authority.
|
||||
|
||||
Keep `_meta.prefixCwd: true`. Remove the leading working-directory envelope only from automatic titles and narrowly defined turn matching; never alter explicit user labels, user-authored content, or user-visible transcript content.
|
||||
|
||||
@@ -7,12 +7,16 @@ ownedPaths:
|
||||
- shared/chat/session-title.ts
|
||||
- electron/services/sessions-api.ts
|
||||
- src/lib/workspace-context.ts
|
||||
- src/stores/settings.ts
|
||||
- src/components/layout/Sidebar.tsx
|
||||
- src/components/layout/session-buckets.ts
|
||||
- src/components/file-preview/ArtifactPanel.tsx
|
||||
- src/components/file-preview/WorkspaceBrowserBody.tsx
|
||||
- src/pages/Chat/ChatInput.tsx
|
||||
- src/pages/Chat/ChatToolbar.tsx
|
||||
- shared/host-api/contract.ts
|
||||
- electron/utils/store.ts
|
||||
- shared/i18n/locales/*/chat.json
|
||||
- tests/unit/workspace-context.test.ts
|
||||
- tests/unit/session-title.test.ts
|
||||
- tests/unit/session-buckets.test.ts
|
||||
@@ -30,6 +34,6 @@ requiredRules:
|
||||
- docs-sync
|
||||
---
|
||||
|
||||
This scenario covers selecting a workspace for a new Chat, binding it through OpenClaw ACP cwd, restoring historical workspace context, navigating workspace-grouped sessions, browsing the effective workspace, and jumping among user questions.
|
||||
This scenario covers selecting a workspace for a new Chat, binding it through OpenClaw ACP cwd, restoring historical workspace context, renaming imported workspace display labels, navigating workspace-grouped sessions, browsing the effective workspace, and jumping among user questions.
|
||||
|
||||
The current resolution, ordering, title normalization, and browser behavior are documented in `harness/reference/chat-workspace-and-navigation.md`.
|
||||
|
||||
@@ -59,6 +59,8 @@ expectedUserBehavior:
|
||||
- Existing sessions use OpenClaw ACP cwd as their read-only workspace context.
|
||||
- Historical sessions with recoverable OpenClaw cwd group under their real cwd.
|
||||
- Sessions without recoverable cwd group under the default workspace label.
|
||||
- Imported workspace display names can be renamed without changing their authoritative paths.
|
||||
- Renamed workspace labels stay synchronized between the sidebar and chat composer, while hover text exposes the path.
|
||||
- OpenClaw ACP cwd injection remains enabled, while automatic conversation titles omit its leading working-directory envelope.
|
||||
- Renderer continues to use host-api and never calls direct IPC or Gateway HTTP.
|
||||
requiredProfiles:
|
||||
@@ -86,6 +88,7 @@ acceptance:
|
||||
- Bound session footer workspace is read-only.
|
||||
- Right workspace tree root matches effective chat workspace.
|
||||
- Sidebar groups sessions by workspace, then sorts each flat group by the shared activity timestamp without date buckets.
|
||||
- Custom workspace labels persist through Main-owned settings and never replace path identity.
|
||||
- Explicit user session labels remain unchanged even when they begin with a working-directory-looking string.
|
||||
docs:
|
||||
required: true
|
||||
|
||||
@@ -125,6 +125,7 @@ export type SettingsSnapshot = Partial<{
|
||||
setupComplete: boolean;
|
||||
chatWorkspacePath: string;
|
||||
recentWorkspacePaths: string[];
|
||||
workspaceLabels: Record<string, string>;
|
||||
}>;
|
||||
export type SettingsKey = keyof SettingsSnapshot & string;
|
||||
export type SettingsValue = SettingsSnapshot[SettingsKey];
|
||||
|
||||
@@ -251,7 +251,11 @@
|
||||
"expandAll": "Expand all",
|
||||
"loadMore": "Load more",
|
||||
"loadMoreForWorkspace": "Load {{count}} more sessions in {{workspace}}",
|
||||
"workspaceToggle": "Toggle workspace {{workspace}}"
|
||||
"workspaceToggle": "Toggle workspace {{workspace}}",
|
||||
"renameWorkspace": "Rename workspace {{workspace}}",
|
||||
"workspaceName": "Workspace name",
|
||||
"saveWorkspaceRename": "Save workspace name",
|
||||
"cancelWorkspaceRename": "Cancel workspace rename"
|
||||
},
|
||||
"historyBuckets": {
|
||||
"today": "Today",
|
||||
|
||||
@@ -251,7 +251,11 @@
|
||||
"expandAll": "すべて展開",
|
||||
"loadMore": "もっと読み込む",
|
||||
"loadMoreForWorkspace": "{{workspace}} でさらに {{count}} 件のセッションを読み込む",
|
||||
"workspaceToggle": "ワークスペース {{workspace}} を切り替え"
|
||||
"workspaceToggle": "ワークスペース {{workspace}} を切り替え",
|
||||
"renameWorkspace": "ワークスペース {{workspace}} の名前を変更",
|
||||
"workspaceName": "ワークスペース名",
|
||||
"saveWorkspaceRename": "ワークスペース名を保存",
|
||||
"cancelWorkspaceRename": "ワークスペース名の変更をキャンセル"
|
||||
},
|
||||
"historyBuckets": {
|
||||
"today": "今日",
|
||||
|
||||
@@ -251,7 +251,11 @@
|
||||
"expandAll": "Развернуть все",
|
||||
"loadMore": "Загрузить еще",
|
||||
"loadMoreForWorkspace": "Загрузить еще {{count}} сессий в {{workspace}}",
|
||||
"workspaceToggle": "Переключить рабочую область {{workspace}}"
|
||||
"workspaceToggle": "Переключить рабочую область {{workspace}}",
|
||||
"renameWorkspace": "Переименовать рабочую область {{workspace}}",
|
||||
"workspaceName": "Название рабочей области",
|
||||
"saveWorkspaceRename": "Сохранить название рабочей области",
|
||||
"cancelWorkspaceRename": "Отменить переименование рабочей области"
|
||||
},
|
||||
"historyBuckets": {
|
||||
"today": "Сегодня",
|
||||
|
||||
@@ -251,7 +251,11 @@
|
||||
"expandAll": "一键展开",
|
||||
"loadMore": "加载更多",
|
||||
"loadMoreForWorkspace": "在 {{workspace}} 中再加载 {{count}} 个会话",
|
||||
"workspaceToggle": "展开或折叠工作空间 {{workspace}}"
|
||||
"workspaceToggle": "展开或折叠工作空间 {{workspace}}",
|
||||
"renameWorkspace": "重命名工作空间 {{workspace}}",
|
||||
"workspaceName": "工作空间名称",
|
||||
"saveWorkspaceRename": "保存工作空间名称",
|
||||
"cancelWorkspaceRename": "取消重命名工作空间"
|
||||
},
|
||||
"historyBuckets": {
|
||||
"today": "今天",
|
||||
|
||||
@@ -48,6 +48,7 @@ import { SIDEBAR_COLLAPSED_WIDTH, MAC_SIDEBAR_CHROME_HEIGHT } from '@shared/side
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import logoSvg from '@/assets/logo.svg';
|
||||
import { useNewChatAction } from './use-new-chat-action';
|
||||
import { isDefaultWorkspacePath } from '@/lib/workspace-context';
|
||||
|
||||
interface NavItemProps {
|
||||
to: string;
|
||||
@@ -111,6 +112,10 @@ export function getWorkspaceGroupToggleTestId(workspacePath: string): string {
|
||||
return `workspace-session-group-toggle-${getWorkspaceTestIdSegment(workspacePath)}`;
|
||||
}
|
||||
|
||||
export function getWorkspaceGroupRenameTestId(workspacePath: string): string {
|
||||
return `workspace-session-group-rename-${getWorkspaceTestIdSegment(workspacePath)}`;
|
||||
}
|
||||
|
||||
function getWorkspaceLoadMoreTestId(workspacePath: string): string {
|
||||
return `workspace-session-load-more-${getWorkspaceTestIdSegment(workspacePath)}`;
|
||||
}
|
||||
@@ -129,6 +134,8 @@ export function Sidebar() {
|
||||
const setSidebarWidth = useSettingsStore((state) => state.setSidebarWidth);
|
||||
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
|
||||
const chatWorkspacePath = useSettingsStore((state) => state.chatWorkspacePath);
|
||||
const workspaceLabels = useSettingsStore((state) => state.workspaceLabels);
|
||||
const setWorkspaceLabel = useSettingsStore((state) => state.setWorkspaceLabel);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const stopResizeRef = useRef<(() => void) | null>(null);
|
||||
|
||||
@@ -205,6 +212,8 @@ export function Sidebar() {
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [editingSessionKey, setEditingSessionKey] = useState<string | null>(null);
|
||||
const [editingLabel, setEditingLabel] = useState('');
|
||||
const [editingWorkspacePath, setEditingWorkspacePath] = useState<string | null>(null);
|
||||
const [editingWorkspaceLabel, setEditingWorkspaceLabel] = useState('');
|
||||
const [nowMs, setNowMs] = useState(INITIAL_NOW_MS);
|
||||
const [collapsedWorkspaceGroups, setCollapsedWorkspaceGroups] = useState<Record<string, boolean>>({});
|
||||
const [workspaceVisibleSessionCounts, setWorkspaceVisibleSessionCounts] = useState<Record<string, number>>({});
|
||||
@@ -262,6 +271,36 @@ export function Sidebar() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartWorkspaceRename = (workspacePath: string, currentLabel: string) => {
|
||||
setEditingWorkspacePath(workspacePath);
|
||||
setEditingWorkspaceLabel(currentLabel);
|
||||
};
|
||||
|
||||
const handleWorkspaceRenameSubmit = () => {
|
||||
if (editingWorkspacePath && editingWorkspaceLabel.trim()) {
|
||||
setWorkspaceLabel(editingWorkspacePath, editingWorkspaceLabel);
|
||||
}
|
||||
setEditingWorkspacePath(null);
|
||||
};
|
||||
|
||||
const handleWorkspaceRenameCancel = () => {
|
||||
setEditingWorkspacePath(null);
|
||||
};
|
||||
|
||||
const handleWorkspaceRenameBlur = (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (event.currentTarget.contains(event.relatedTarget as Node | null)) return;
|
||||
handleWorkspaceRenameSubmit();
|
||||
};
|
||||
|
||||
const handleWorkspaceRenameKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleWorkspaceRenameSubmit();
|
||||
} else if (event.key === 'Escape') {
|
||||
handleWorkspaceRenameCancel();
|
||||
}
|
||||
};
|
||||
|
||||
const toggleWorkspaceGroup = (workspacePath: string) => {
|
||||
const stateKey = getWorkspaceGroupStateKey(workspacePath);
|
||||
setCollapsedWorkspaceGroups((current) => ({
|
||||
@@ -330,6 +369,7 @@ export function Sidebar() {
|
||||
sessionLastActivity,
|
||||
t('chat:workspace.defaultLabel'),
|
||||
chatWorkspacePath,
|
||||
workspaceLabels,
|
||||
);
|
||||
const allWorkspaceGroupsCollapsed = workspaceSessionGroups.length > 0
|
||||
&& workspaceSessionGroups.every((group) => collapsedWorkspaceGroups[getWorkspaceGroupStateKey(group.workspacePath)] ?? false);
|
||||
@@ -520,26 +560,79 @@ export function Sidebar() {
|
||||
data-testid={getWorkspaceGroupTestId(workspaceGroup.workspacePath)}
|
||||
className="space-y-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid={getWorkspaceGroupToggleTestId(workspaceGroup.workspacePath)}
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={t('chat:sessionList.workspaceToggle', { workspace: workspaceGroup.label })}
|
||||
onClick={() => toggleWorkspaceGroup(workspaceGroup.workspacePath)}
|
||||
className="flex w-full items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-left text-meta font-semibold text-foreground/75 transition-colors hover:bg-black/5 hover:text-foreground dark:hover:bg-white/5"
|
||||
title={workspaceGroup.label}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform',
|
||||
!collapsed && 'rotate-90',
|
||||
{editingWorkspacePath === workspaceGroup.workspacePath ? (
|
||||
<div
|
||||
className="flex w-full items-center gap-1 px-1.5 py-1"
|
||||
onBlur={handleWorkspaceRenameBlur}
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
value={editingWorkspaceLabel}
|
||||
onChange={(event) => setEditingWorkspaceLabel(event.target.value)}
|
||||
onKeyDown={handleWorkspaceRenameKeyDown}
|
||||
className="h-7 min-w-0 flex-1 text-meta"
|
||||
aria-label={t('chat:sessionList.workspaceName')}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('chat:sessionList.saveWorkspaceRename')}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={handleWorkspaceRenameSubmit}
|
||||
className="flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('chat:sessionList.cancelWorkspaceRename')}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={handleWorkspaceRenameCancel}
|
||||
className="flex shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="group flex items-center rounded-lg transition-colors hover:bg-black/5 dark:hover:bg-white/5">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={getWorkspaceGroupToggleTestId(workspaceGroup.workspacePath)}
|
||||
aria-expanded={!collapsed}
|
||||
aria-label={t('chat:sessionList.workspaceToggle', { workspace: workspaceGroup.label })}
|
||||
onClick={() => toggleWorkspaceGroup(workspaceGroup.workspacePath)}
|
||||
onDoubleClick={() => {
|
||||
if (!isDefaultWorkspacePath(workspaceGroup.workspacePath)) {
|
||||
handleStartWorkspaceRename(workspaceGroup.workspacePath, workspaceGroup.label);
|
||||
}
|
||||
}}
|
||||
className="flex min-w-0 flex-1 items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-left text-meta font-semibold text-foreground/75 transition-colors hover:text-foreground"
|
||||
title={workspaceGroup.workspacePath}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform',
|
||||
!collapsed && 'rotate-90',
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{workspaceGroup.label}</span>
|
||||
<span className="shrink-0 text-2xs font-medium text-muted-foreground/60 group-hover:hidden group-focus-within:hidden">
|
||||
{workspaceGroup.sessions.length}
|
||||
</span>
|
||||
</button>
|
||||
{!isDefaultWorkspacePath(workspaceGroup.workspacePath) && (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={getWorkspaceGroupRenameTestId(workspaceGroup.workspacePath)}
|
||||
aria-label={t('chat:sessionList.renameWorkspace', { workspace: workspaceGroup.label })}
|
||||
title={t('chat:sessionList.renameWorkspace', { workspace: workspaceGroup.label })}
|
||||
onClick={() => handleStartWorkspaceRename(workspaceGroup.workspacePath, workspaceGroup.label)}
|
||||
className="mr-2 hidden shrink-0 items-center justify-center rounded p-0.5 text-muted-foreground hover:bg-black/5 hover:text-foreground group-hover:flex group-focus-within:flex dark:hover:bg-white/10"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate">{workspaceGroup.label}</span>
|
||||
<span className="shrink-0 text-2xs font-medium text-muted-foreground/60">
|
||||
{workspaceGroup.sessions.length}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5">
|
||||
|
||||
@@ -66,6 +66,7 @@ export function groupSessionsByWorkspace<TSession extends ChatSession>(
|
||||
sessionLastActivity: Record<string, number>,
|
||||
defaultWorkspaceLabel: string,
|
||||
globalWorkspace?: string | null,
|
||||
workspaceLabels: Record<string, string> = {},
|
||||
): Array<WorkspaceSessionGroup<TSession>> {
|
||||
const groupByWorkspace = new Map<string, WorkspaceSessionGroup<TSession>>();
|
||||
|
||||
@@ -75,7 +76,7 @@ export function groupSessionsByWorkspace<TSession extends ChatSession>(
|
||||
if (!group) {
|
||||
group = {
|
||||
workspacePath,
|
||||
label: getWorkspaceDisplayLabel(workspacePath, defaultWorkspaceLabel),
|
||||
label: getWorkspaceDisplayLabel(workspacePath, defaultWorkspaceLabel, workspaceLabels),
|
||||
sessions: [],
|
||||
};
|
||||
groupByWorkspace.set(workspacePath, group);
|
||||
|
||||
@@ -54,9 +54,14 @@ export function formatWorkspacePath(workspace: string): string {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function getWorkspaceDisplayLabel(workspace: string | null | undefined, defaultLabel: string): string {
|
||||
export function getWorkspaceDisplayLabel(
|
||||
workspace: string | null | undefined,
|
||||
defaultLabel: string,
|
||||
workspaceLabels: Record<string, string> = {},
|
||||
): string {
|
||||
const normalized = normalizeWorkspacePath(workspace) ?? DEFAULT_WORKSPACE_CWD;
|
||||
return isDefaultWorkspacePath(normalized) ? defaultLabel : formatWorkspacePath(normalized);
|
||||
if (isDefaultWorkspacePath(normalized)) return defaultLabel;
|
||||
return workspaceLabels[normalized]?.trim() || formatWorkspacePath(normalized);
|
||||
}
|
||||
|
||||
export function resolveEffectiveWorkspace(input: {
|
||||
|
||||
@@ -133,6 +133,7 @@ export function Chat() {
|
||||
const selectAcpSession = useChatStore((s) => s.selectAcpSession);
|
||||
const acknowledgeAcpSessionCreated = useChatStore((s) => s.acknowledgeAcpSessionCreated);
|
||||
const chatWorkspacePath = useSettingsStore((s) => s.chatWorkspacePath);
|
||||
const workspaceLabels = useSettingsStore((s) => s.workspaceLabels);
|
||||
const setChatWorkspacePath = useSettingsStore((s) => s.setChatWorkspacePath);
|
||||
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||
const agents = useAgentsStore((s) => s.agents);
|
||||
@@ -154,7 +155,7 @@ export function Chat() {
|
||||
[chatWorkspacePath, currentSession],
|
||||
);
|
||||
const cwd = effectiveWorkspace.cwd;
|
||||
const workspaceLabel = getWorkspaceDisplayLabel(cwd, t('workspace.defaultLabel'));
|
||||
const workspaceLabel = getWorkspaceDisplayLabel(cwd, t('workspace.defaultLabel'), workspaceLabels);
|
||||
const currentAgent = useMemo(
|
||||
() => (agents ?? []).find((agent) => agent.id === currentAgentId) ?? null,
|
||||
[agents, currentAgentId],
|
||||
|
||||
@@ -40,6 +40,7 @@ interface SettingsState {
|
||||
devModeUnlocked: boolean;
|
||||
chatWorkspacePath: string;
|
||||
recentWorkspacePaths: string[];
|
||||
workspaceLabels: Record<string, string>;
|
||||
|
||||
// Setup
|
||||
setupComplete: boolean;
|
||||
@@ -65,6 +66,7 @@ interface SettingsState {
|
||||
setSidebarWidth: (value: number) => void;
|
||||
setDevModeUnlocked: (value: boolean) => void;
|
||||
setChatWorkspacePath: (workspacePath: string) => void;
|
||||
setWorkspaceLabel: (workspacePath: string, label: string) => void;
|
||||
markSetupComplete: () => void;
|
||||
resetSettings: () => void;
|
||||
}
|
||||
@@ -90,6 +92,7 @@ const defaultSettings = {
|
||||
devModeUnlocked: false,
|
||||
chatWorkspacePath: DEFAULT_WORKSPACE_CWD,
|
||||
recentWorkspacePaths: [DEFAULT_WORKSPACE_CWD],
|
||||
workspaceLabels: {},
|
||||
setupComplete: false,
|
||||
};
|
||||
|
||||
@@ -179,6 +182,19 @@ export const useSettingsStore = create<SettingsState>()(
|
||||
return { chatWorkspacePath: normalized, recentWorkspacePaths };
|
||||
});
|
||||
},
|
||||
setWorkspaceLabel: (workspacePath, label) => {
|
||||
const normalizedPath = workspacePath.trim();
|
||||
const normalizedLabel = label.trim();
|
||||
if (!normalizedPath || !normalizedLabel) return;
|
||||
set((state) => {
|
||||
const workspaceLabels = {
|
||||
...state.workspaceLabels,
|
||||
[normalizedPath]: normalizedLabel,
|
||||
};
|
||||
void hostApi.settings.setMany({ workspaceLabels }).catch(() => { });
|
||||
return { workspaceLabels };
|
||||
});
|
||||
},
|
||||
markSetupComplete: () => set({ setupComplete: true }),
|
||||
resetSettings: () => set(defaultSettings),
|
||||
}),
|
||||
|
||||
@@ -88,6 +88,7 @@ async function getWorkspaceTreeRequests(app: ElectronApplication) {
|
||||
type WorkspaceMockOptions = {
|
||||
chatWorkspacePath?: string;
|
||||
recentWorkspacePaths?: string[];
|
||||
workspaceLabels?: Record<string, string>;
|
||||
sessionHistory?: Array<{ role: string; content: unknown; timestamp?: number }>;
|
||||
sessionDerivedTitle?: string | null;
|
||||
sessionSummaryFirstUserText?: string | null;
|
||||
@@ -102,6 +103,7 @@ async function installWorkspaceMocks(app: ElectronApplication, options: Workspac
|
||||
setupComplete: true,
|
||||
chatWorkspacePath: options.chatWorkspacePath ?? DEFAULT_WORKSPACE,
|
||||
recentWorkspacePaths: options.recentWorkspacePaths ?? [DEFAULT_WORKSPACE],
|
||||
workspaceLabels: options.workspaceLabels ?? {},
|
||||
};
|
||||
const sessionRow = {
|
||||
key: SESSION_KEY,
|
||||
@@ -145,6 +147,9 @@ async function installWorkspaceMocks(app: ElectronApplication, options: Workspac
|
||||
},
|
||||
hostApi: {
|
||||
[stableStringify(['settings', 'getAll', null])]: settingsSnapshot,
|
||||
[stableStringify(['settings', 'setMany', {
|
||||
patch: { workspaceLabels: { [SESSION_WORKSPACE]: 'Renamed workspace' } },
|
||||
}])]: { success: true },
|
||||
[stableStringify(['/api/settings', 'GET'])]: hostJson(settingsSnapshot),
|
||||
[stableStringify(['/api/gateway/status', 'GET'])]: hostJson(gatewayStatus),
|
||||
[stableStringify(['/api/agents', 'GET'])]: hostJson({
|
||||
@@ -201,6 +206,19 @@ test.describe('ClawX chat workspace context', () => {
|
||||
await expect(workspaceGroup).toBeVisible();
|
||||
await expect(workspaceGroup).toContainText('Workspace chat');
|
||||
await expect(workspaceGroup).not.toContainText('[Working directory:');
|
||||
const workspaceToggle = workspaceGroup.getByRole('button', {
|
||||
name: `Toggle workspace ${SESSION_WORKSPACE_LABEL}`,
|
||||
});
|
||||
await expect(workspaceToggle).toHaveAttribute('title', SESSION_WORKSPACE);
|
||||
|
||||
await workspaceToggle.hover();
|
||||
await workspaceGroup.getByRole('button', { name: `Rename workspace ${SESSION_WORKSPACE_LABEL}` }).click();
|
||||
await workspaceGroup.getByRole('textbox', { name: 'Workspace name' }).fill('Renamed workspace');
|
||||
await workspaceGroup.getByRole('button', { name: 'Save workspace name' }).click();
|
||||
|
||||
await expect(workspaceGroup).toContainText('Renamed workspace');
|
||||
await expect(workspaceSelector).toHaveText('Renamed workspace');
|
||||
await expect(workspaceSelector).toHaveAttribute('title', SESSION_WORKSPACE);
|
||||
|
||||
await page.getByTestId('chat-toolbar-workspace').click();
|
||||
|
||||
@@ -209,14 +227,14 @@ test.describe('ClawX chat workspace context', () => {
|
||||
await sidePanel.getByTestId('artifact-panel-tab-browser').click();
|
||||
|
||||
const workspaceHeader = sidePanel.getByTestId('workspace-header-title');
|
||||
const expectedWorkspaceHeader = `Agent: Main Agent · Directory: ${SESSION_WORKSPACE_LABEL}`;
|
||||
const expectedWorkspaceHeader = 'Agent: Main Agent · Directory: Renamed workspace';
|
||||
await expect(workspaceHeader).toHaveAttribute('title', expectedWorkspaceHeader);
|
||||
await expect(workspaceHeader).toHaveAttribute('aria-label', expectedWorkspaceHeader);
|
||||
await expect(sidePanel.getByTestId('workspace-agent-tag')).toHaveText('Main Agent');
|
||||
await expect(sidePanel.getByTestId('workspace-agent-tag')).toHaveAttribute('title', 'Main Agent');
|
||||
await expect(sidePanel.getByTestId('workspace-path-tag')).toHaveText(SESSION_WORKSPACE_LABEL);
|
||||
await expect(sidePanel.getByTestId('workspace-path-tag')).toHaveText('Renamed workspace');
|
||||
await expect(sidePanel.getByTestId('workspace-path-tag')).toHaveAttribute('title', SESSION_WORKSPACE);
|
||||
await expect(sidePanel.getByTestId('workspace-path-final-segment')).toHaveText('ClawX');
|
||||
await expect(sidePanel.getByTestId('workspace-path-final-segment')).toHaveText('Renamed workspace');
|
||||
await expect(sidePanel.getByTestId('workspace-tree')).toBeVisible({ timeout: 30_000 });
|
||||
await expect(sidePanel.getByText('README.md')).toBeVisible();
|
||||
|
||||
|
||||
@@ -64,6 +64,20 @@ describe('workspace session grouping', () => {
|
||||
expect(groups[0].workspacePath).toBe('/repo/global');
|
||||
});
|
||||
|
||||
it('uses custom workspace labels without changing path-based grouping', () => {
|
||||
const groups = groupSessionsByWorkspace(
|
||||
[{ key: 'agent:main:session-project', workspacePath: '/repo/project', updatedAt: 1 }],
|
||||
{},
|
||||
'默认工作空间',
|
||||
undefined,
|
||||
{ '/repo/project': '产品项目' },
|
||||
);
|
||||
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].workspacePath).toBe('/repo/project');
|
||||
expect(groups[0].label).toBe('产品项目');
|
||||
});
|
||||
|
||||
it('groups default-equivalent workspace paths with sessions missing cwd', () => {
|
||||
const groups = groupSessionsByWorkspace(
|
||||
[
|
||||
|
||||
@@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getSessionActivityMs } from '@/components/layout/session-buckets';
|
||||
import {
|
||||
Sidebar,
|
||||
getWorkspaceGroupRenameTestId,
|
||||
getWorkspaceGroupStateKey,
|
||||
getWorkspaceGroupTestId,
|
||||
getWorkspaceGroupToggleTestId,
|
||||
@@ -195,6 +196,30 @@ describe('sidebar session helpers', () => {
|
||||
expect(screen.queryByLabelText('Session title')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renames imported workspaces while keeping the path as hover text', () => {
|
||||
const workspacePath = '/repo/imported';
|
||||
const setWorkspaceLabel = vi.fn();
|
||||
seedSidebarState();
|
||||
useSettingsStore.setState({ workspaceLabels: {}, setWorkspaceLabel });
|
||||
useChatStore.setState({
|
||||
sessions: [{
|
||||
key: sidebarSessionKey,
|
||||
displayName: 'Workspace chat',
|
||||
workspacePath,
|
||||
updatedAt: 1,
|
||||
}],
|
||||
});
|
||||
|
||||
renderSidebar();
|
||||
|
||||
expect(screen.getByTestId(getWorkspaceGroupToggleTestId(workspacePath))).toHaveAttribute('title', workspacePath);
|
||||
fireEvent.click(screen.getByTestId(getWorkspaceGroupRenameTestId(workspacePath)));
|
||||
fireEvent.change(screen.getByLabelText('Workspace name'), { target: { value: 'Imported project' } });
|
||||
fireEvent.click(screen.getByLabelText('Save workspace name'));
|
||||
|
||||
expect(setWorkspaceLabel).toHaveBeenCalledWith(workspacePath, 'Imported project');
|
||||
});
|
||||
|
||||
it('uses the timestamp embedded in a locally-created session key as activity fallback', () => {
|
||||
const createdAtMs = new Date('2026-05-06T10:00:00.000Z').getTime();
|
||||
const session = {
|
||||
|
||||
@@ -51,6 +51,11 @@ describe('workspace context helpers', () => {
|
||||
it('formats labels for default and non-default workspaces', () => {
|
||||
expect(getWorkspaceDisplayLabel('~/.openclaw/workspace', '默认工作空间')).toBe('默认工作空间');
|
||||
expect(getWorkspaceDisplayLabel('/Users/alex/workspace/ClawX', '默认工作空间')).toBe('~/workspace/ClawX');
|
||||
expect(getWorkspaceDisplayLabel(
|
||||
'/Users/alex/workspace/ClawX',
|
||||
'默认工作空间',
|
||||
{ '/Users/alex/workspace/ClawX': '我的项目' },
|
||||
)).toBe('我的项目');
|
||||
expect(formatWorkspacePath('/home/alex/project')).toBe('~/project');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user