mirror of
https://github.com/ValueCell-ai/ClawX.git
synced 2026-08-14 00:48:10 +00:00
fix chat workspace menu and session fallback titles (#1187)
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 設定に依存します。
|
||||
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができ、行にはホバーで操作ボタンが出るまで相対アクティビティ時刻が表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。編集可能なチャットでは、コンポーザーのワークスペースチップから既定ワークスペースへ戻すか別フォルダーを選ぶ小さなメニューを開けます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。
|
||||
セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができ、行にはホバーで操作ボタンが出るまで相対アクティビティ時刻が表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。編集可能なチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。
|
||||
各 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. 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. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group.
|
||||
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 that lists recent and conversation workspaces, returns to the default workspace, or chooses another folder. If a saved workspace folder was moved or deleted, Chat pauses session creation and prompts you to choose an existing folder instead of repeatedly retrying the missing path. Unavailable non-default groups are marked in the sidebar and can be removed after confirmation; this permanently deletes every session in that group. Synthetic OpenClaw UUID-date fallback titles are replaced with the conversation's first user prompt rather than being saved as the session name.
|
||||
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 配置。
|
||||
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话,行内会显示相对活跃时间直到悬停时露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。可编辑的新对话中,输入框的工作空间卡片会打开一个小菜单,可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。
|
||||
会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话,行内会显示相对活跃时间直到悬停时露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。可编辑的新对话中,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。OpenClaw 生成的 UUID 加日期兜底标题会改用会话的首条用户消息展示,不会再被保存成会话名称。
|
||||
每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。
|
||||
|
||||
### 📡 多频道管理
|
||||
|
||||
@@ -18,13 +18,13 @@ 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 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 intentionally use the target agent workspace and remain an explicit branch. Navigation records that workspace on the target session placeholder before reactive loading; a newly targeted agent's first send creates its main ACP session and shares one load identity with the prompt so navigation cannot supersede delivery.
|
||||
ClawX persists global and recent workspace selections plus custom display labels through Main-owned settings APIs. On editable new or unbound chats, the composer menu shows the canonical default once, followed by deduplicated non-default workspaces from the most-recent list and known session paths, then the native folder picker. Recent entries stay first; all entries use custom display labels when available, keep the full path as hover text, and update only the global selection until first send binds the session. 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 intentionally use the target agent workspace and remain an explicit branch. Navigation records that workspace on the target session placeholder before reactive loading; a newly targeted agent's first send creates its main ACP session and shares one load identity with the prompt so navigation cannot supersede delivery.
|
||||
|
||||
## First Send And Titles
|
||||
|
||||
First send initializes the ACP session with the selected cwd and then marks the local session as created/bound. ACP keeps `_meta.prefixCwd: true`; disabling cwd injection would break OpenClaw context. Automatic titles instead normalize away one leading `[Working directory: ...]` envelope and subsequent whitespace.
|
||||
|
||||
Normalization applies to automatic sources such as Gateway-derived title and Main transcript summary. It never changes an explicit user label, never removes a non-leading marker, and treats the exact truncated envelope form as a missing title so a better summary can replace it.
|
||||
Normalization applies to automatic sources such as Gateway-derived title and Main transcript summary. It never changes an explicit user label, never removes a non-leading marker, and treats the exact truncated envelope form as a missing title so a better summary can replace it. OpenClaw's synthetic `<first 8 session UUID characters> (YYYY-MM-DD)` fallback is also treated as missing only when it matches the row's full session id; Main's transcript summary then supplies the first user prompt. Opening and closing rename mode without changing the value must not persist any displayed fallback as an explicit label.
|
||||
|
||||
## Sidebar Navigation
|
||||
|
||||
@@ -53,6 +53,6 @@ The Chat question directory belongs to the active ACP timeline rather than works
|
||||
|
||||
## Validation Anchors
|
||||
|
||||
Key tests include `tests/unit/workspace-context.test.ts`, `tests/unit/session-title.test.ts`, `tests/unit/session-buckets.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/workspace-browser-body.test.tsx`, `tests/unit/chat-acp-page.test.tsx`, `tests/e2e/chat-workspace-context.spec.ts`, `tests/e2e/chat-acp-inline-timeline.spec.ts`, and `tests/e2e/chat-question-directory.spec.ts`.
|
||||
Key tests include `tests/unit/workspace-context.test.ts`, `tests/unit/session-title.test.ts`, `tests/unit/session-buckets.test.ts`, `tests/unit/sidebar-session-buckets.test.ts`, `tests/unit/workspace-browser-body.test.tsx`, `tests/unit/chat-acp-page.test.tsx`, recent-workspace selection coverage in `tests/e2e/chat-workspace-context.spec.ts`, `tests/e2e/chat-acp-inline-timeline.spec.ts`, and `tests/e2e/chat-question-directory.spec.ts`.
|
||||
|
||||
This reference consolidates the former workspace sidebar, chat workspace context, sidebar workspace UI, and ACP working-directory title designs. The later flat activity-sorted sidebar supersedes the earlier recency buckets.
|
||||
|
||||
@@ -8,7 +8,7 @@ appliesTo:
|
||||
- gateway-backend-communication
|
||||
---
|
||||
|
||||
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. Main validates the effective workspace before ACP load. Missing paths surface a localized unavailable state instead of repeatedly loading or silently changing roots; only new or unbound sessions may offer an action that replaces the global workspace.
|
||||
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. Editable new or unbound sessions expose persisted recent selections and workspace paths represented by known sessions in the composer; the canonical default appears once, non-default paths are deduplicated, and choosing one updates only the global workspace until the first send binds the session. Main validates the effective workspace before ACP load. Missing paths surface a localized unavailable state instead of repeatedly loading or silently changing roots; only new or unbound sessions may offer an action that replaces the global workspace.
|
||||
|
||||
Sidebar group deletion is available only after Main confirms that a non-default workspace is unavailable. It permanently hard-deletes every successfully targeted session through the existing Main-owned session deletion boundary, keeps failed sessions visible, and never treats removal of display metadata as a substitute for deleting transcripts. Per-session deletes run sequentially so updates to an agent's sessions index cannot race.
|
||||
|
||||
@@ -18,4 +18,4 @@ Targeted `@agent` sends establish the target session placeholder with the target
|
||||
|
||||
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.
|
||||
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. Treat OpenClaw's `<session UUID prefix> (YYYY-MM-DD)` title as synthetic only when the prefix matches that row's full session id; hydrate it from the first user prompt instead, and never persist an unchanged rename value.
|
||||
|
||||
@@ -39,6 +39,6 @@ requiredRules:
|
||||
- docs-sync
|
||||
---
|
||||
|
||||
This scenario covers selecting a workspace for a new Chat, validating its availability before ACP load, recovering from deleted global workspace paths, marking unavailable non-default sidebar groups, permanently deleting their sessions after confirmation, binding workspaces through OpenClaw ACP cwd, targeting another agent without losing that agent's workspace or first prompt, restoring historical workspace context, renaming imported workspace display labels, navigating workspace-grouped sessions, browsing the effective workspace, and jumping among user questions.
|
||||
This scenario covers selecting persisted recent, known session, or newly browsed workspaces for a new Chat, validating workspace availability before ACP load, recovering from deleted global workspace paths, marking unavailable non-default sidebar groups, permanently deleting their sessions after confirmation, binding workspaces through OpenClaw ACP cwd, targeting another agent without losing that agent's workspace or first prompt, restoring historical workspace context, replacing synthetic UUID-date session titles with transcript prompts, 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`.
|
||||
|
||||
@@ -55,6 +55,7 @@ touchedAreas:
|
||||
- tests/e2e/chat-workspace-context.spec.ts
|
||||
expectedUserBehavior:
|
||||
- New chat sessions use the globally selected workspace until their first send.
|
||||
- Editable new chats list persisted recent and known session workspaces in the composer menu, alongside the default workspace and native folder picker.
|
||||
- First send initializes the OpenClaw ACP session with the selected cwd.
|
||||
- Existing sessions use OpenClaw ACP cwd as their read-only workspace context.
|
||||
- Historical sessions with recoverable OpenClaw cwd group under their real cwd.
|
||||
@@ -62,6 +63,7 @@ expectedUserBehavior:
|
||||
- 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.
|
||||
- OpenClaw UUID-date fallback titles are replaced by the transcript's first user prompt and are never persisted by an unchanged rename.
|
||||
- Renderer continues to use host-api and never calls direct IPC or Gateway HTTP.
|
||||
requiredProfiles:
|
||||
- fast
|
||||
@@ -85,11 +87,13 @@ requiredTests:
|
||||
acceptance:
|
||||
- OpenClaw ACP cwd is the authoritative session workspace when available.
|
||||
- ClawX only persists global workspace selection and recent workspaces.
|
||||
- The editable composer workspace menu shows deduplicated recent and known-session non-default workspaces with their custom display labels.
|
||||
- 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.
|
||||
- A UUID-date fallback matching the OpenClaw session id is not treated as an explicit or derived user-facing title.
|
||||
docs:
|
||||
required: true
|
||||
---
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const ACP_WORKING_DIRECTORY_PREFIX = /^\[Working directory: [^\r\n]*\](?:\r?\n){0,2}/
|
||||
const ACP_WORKING_DIRECTORY_TRUNCATED_TITLE = /^\[Working directory: [^\r\n]*\]…$/
|
||||
const OPENCLAW_SESSION_ID_FALLBACK_TITLE = /^([0-9a-f]{8}) \((\d{4}-\d{2}-\d{2})\)$/i
|
||||
|
||||
export function stripAcpWorkingDirectoryPrefix(text: string): string {
|
||||
return text.replace(ACP_WORKING_DIRECTORY_PREFIX, '')
|
||||
@@ -8,3 +9,13 @@ export function stripAcpWorkingDirectoryPrefix(text: string): string {
|
||||
export function isAcpWorkingDirectoryTruncatedTitle(text: string): boolean {
|
||||
return ACP_WORKING_DIRECTORY_TRUNCATED_TITLE.test(text.trim())
|
||||
}
|
||||
|
||||
export function isOpenClawSessionIdFallbackTitle(
|
||||
text: string,
|
||||
sessionId: string | null | undefined,
|
||||
): boolean {
|
||||
const normalizedSessionId = typeof sessionId === 'string' ? sessionId.trim().toLowerCase() : ''
|
||||
if (!normalizedSessionId) return false
|
||||
const match = text.trim().match(OPENCLAW_SESSION_ID_FALLBACK_TITLE)
|
||||
return Boolean(match && normalizedSessionId.startsWith(match[1]!.toLowerCase()))
|
||||
}
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface ContentBlock {
|
||||
/** Session from sessions.list */
|
||||
export interface ChatSession {
|
||||
key: string;
|
||||
/** OpenClaw transcript session UUID, used to identify synthetic fallback titles. */
|
||||
sessionId?: string;
|
||||
label?: string;
|
||||
displayName?: string;
|
||||
derivedTitle?: string;
|
||||
|
||||
@@ -33,7 +33,7 @@ import { cn } from '@/lib/utils';
|
||||
import { isGatewayRestarting } from '@/lib/gateway-status';
|
||||
import { rendererExtensionRegistry } from '@/extensions/registry';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useChatStore } from '@/stores/chat';
|
||||
import { useChatStore, type ChatSession } from '@/stores/chat';
|
||||
import { useGatewayStore } from '@/stores/gateway';
|
||||
import { useAgentsStore } from '@/stores/agents';
|
||||
import { groupSessionsByWorkspace } from './session-buckets';
|
||||
@@ -51,6 +51,7 @@ import logoSvg from '@/assets/logo.svg';
|
||||
import { useNewChatAction } from './use-new-chat-action';
|
||||
import { isDefaultWorkspacePath } from '@/lib/workspace-context';
|
||||
import { useWorkspaceAvailability } from '@/hooks/use-workspace-availability';
|
||||
import { isOpenClawSessionIdFallbackTitle } from '@shared/chat/session-title';
|
||||
|
||||
interface NavItemProps {
|
||||
to: string;
|
||||
@@ -195,8 +196,18 @@ export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
const isOnChat = useLocation().pathname === '/';
|
||||
|
||||
const getSessionLabel = (key: string, displayName?: string, label?: string) =>
|
||||
sessionLabels[key] ?? (label?.trim() ? label : displayName ?? key);
|
||||
const getSessionLabel = (session: ChatSession) => {
|
||||
const candidates = [
|
||||
sessionLabels[session.key],
|
||||
session.label,
|
||||
session.derivedTitle,
|
||||
session.displayName,
|
||||
];
|
||||
return candidates.find((candidate) => (
|
||||
candidate?.trim()
|
||||
&& !isOpenClawSessionIdFallbackTitle(candidate, session.sessionId)
|
||||
))?.trim() ?? session.key;
|
||||
};
|
||||
|
||||
const openControlUi = async (view?: 'dreams', label = 'OpenClaw Page') => {
|
||||
try {
|
||||
@@ -226,6 +237,7 @@ export function Sidebar() {
|
||||
const [workspaceDeleteDialogOpen, setWorkspaceDeleteDialogOpen] = useState(false);
|
||||
const [editingSessionKey, setEditingSessionKey] = useState<string | null>(null);
|
||||
const [editingLabel, setEditingLabel] = useState('');
|
||||
const [editingOriginalLabel, setEditingOriginalLabel] = useState('');
|
||||
const [editingWorkspacePath, setEditingWorkspacePath] = useState<string | null>(null);
|
||||
const [editingWorkspaceLabel, setEditingWorkspaceLabel] = useState('');
|
||||
const [nowMs, setNowMs] = useState(INITIAL_NOW_MS);
|
||||
@@ -258,23 +270,28 @@ export function Sidebar() {
|
||||
const handleStartRename = (key: string, currentLabel: string) => {
|
||||
setEditingSessionKey(key);
|
||||
setEditingLabel(currentLabel);
|
||||
setEditingOriginalLabel(currentLabel);
|
||||
};
|
||||
|
||||
const handleRenameSubmit = async () => {
|
||||
if (!editingSessionKey || !editingLabel.trim()) {
|
||||
const normalizedLabel = editingLabel.trim();
|
||||
if (!editingSessionKey || !normalizedLabel || normalizedLabel === editingOriginalLabel.trim()) {
|
||||
setEditingSessionKey(null);
|
||||
setEditingOriginalLabel('');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await renameSession(editingSessionKey, editingLabel.trim());
|
||||
await renameSession(editingSessionKey, normalizedLabel);
|
||||
} catch (err) {
|
||||
console.error('Failed to rename session:', err);
|
||||
}
|
||||
setEditingSessionKey(null);
|
||||
setEditingOriginalLabel('');
|
||||
};
|
||||
|
||||
const handleRenameCancel = () => {
|
||||
setEditingSessionKey(null);
|
||||
setEditingOriginalLabel('');
|
||||
};
|
||||
|
||||
const handleRenameBlur = (event: React.FocusEvent<HTMLDivElement>) => {
|
||||
@@ -698,7 +715,7 @@ export function Sidebar() {
|
||||
const agentName = agentNameById[agentId] || agentId;
|
||||
const isEditing = editingSessionKey === s.key;
|
||||
const isCurrentSession = isOnChat && currentSessionKey === s.key;
|
||||
const sessionLabel = getSessionLabel(s.key, s.displayName, s.label);
|
||||
const sessionLabel = getSessionLabel(s);
|
||||
const relativeTime = formatSessionRelativeTime(activityMs, nowMs, i18n.language);
|
||||
const channelType = s.channel && s.channel !== 'webchat' ? s.channel : null;
|
||||
const channelName = channelType
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* are sent with the message (no base64 over WebSocket).
|
||||
*/
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, FolderOpen, Loader2, AtSign, Search, ChevronDown } from 'lucide-react';
|
||||
import { SendHorizontal, Square, X, Paperclip, FileText, Film, Music, FileArchive, File, FolderOpen, Loader2, AtSign, Search, ChevronDown, Check } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
@@ -27,7 +27,7 @@ import { toast } from 'sonner';
|
||||
import { rendererExtensionRegistry } from '@/extensions/registry';
|
||||
import { collectDroppedFiles } from '@/lib/collect-dropped-files';
|
||||
import { fetchQuickAccessSkills } from '@/lib/quick-access-skills';
|
||||
import { DEFAULT_WORKSPACE_CWD } from '@/lib/workspace-context';
|
||||
import { DEFAULT_WORKSPACE_CWD, isDefaultWorkspacePath, normalizeWorkspacePath } from '@/lib/workspace-context';
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -42,6 +42,11 @@ export interface FileAttachment {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface ChatWorkspaceOption {
|
||||
path: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ChatInputProps {
|
||||
onSend: (text: string, attachments?: FileAttachment[], targetAgentId?: string | null) => void;
|
||||
onStop?: () => void;
|
||||
@@ -50,6 +55,7 @@ interface ChatInputProps {
|
||||
imageGenerating?: boolean;
|
||||
workspaceLabel?: string;
|
||||
workspacePath?: string;
|
||||
workspaceOptions?: ChatWorkspaceOption[];
|
||||
workspaceReadOnly?: boolean;
|
||||
onSelectWorkspace?: (path: string) => void;
|
||||
}
|
||||
@@ -205,6 +211,7 @@ export function ChatInput({
|
||||
imageGenerating = false,
|
||||
workspaceLabel,
|
||||
workspacePath,
|
||||
workspaceOptions = [],
|
||||
workspaceReadOnly = false,
|
||||
onSelectWorkspace,
|
||||
}: ChatInputProps) {
|
||||
@@ -537,13 +544,17 @@ export function ChatInput({
|
||||
event.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleSelectDefaultWorkspace = useCallback(() => {
|
||||
const handleSelectWorkspace = useCallback((path: string) => {
|
||||
if (workspaceSelectorDisabled || !onSelectWorkspace) return;
|
||||
onSelectWorkspace(DEFAULT_WORKSPACE_CWD);
|
||||
onSelectWorkspace(path);
|
||||
setWorkspaceMenuOpen(false);
|
||||
textareaRef.current?.focus();
|
||||
}, [onSelectWorkspace, workspaceSelectorDisabled]);
|
||||
|
||||
const handleSelectDefaultWorkspace = useCallback(() => {
|
||||
handleSelectWorkspace(DEFAULT_WORKSPACE_CWD);
|
||||
}, [handleSelectWorkspace]);
|
||||
|
||||
const handleChooseOtherWorkspace = useCallback(async () => {
|
||||
if (workspaceSelectorDisabled || !onSelectWorkspace) return;
|
||||
setWorkspaceMenuOpen(false);
|
||||
@@ -1234,25 +1245,54 @@ export function ChatInput({
|
||||
{workspaceMenuOpen && !workspaceSelectorDisabled && (
|
||||
<div
|
||||
data-testid="chat-workspace-menu"
|
||||
className="absolute bottom-full left-0 z-20 mb-2 w-60 overflow-hidden rounded-2xl border border-black/10 bg-surface-modal p-1.5 shadow-xl dark:border-white/10"
|
||||
className="absolute bottom-full left-0 z-20 mb-2 max-h-80 w-64 overflow-y-auto rounded-2xl border border-black/10 bg-surface-modal p-1.5 shadow-xl dark:border-white/10"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-workspace-default"
|
||||
aria-current={isDefaultWorkspacePath(workspacePath) ? 'true' : undefined}
|
||||
onClick={handleSelectDefaultWorkspace}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10',
|
||||
isDefaultWorkspacePath(workspacePath) && 'bg-black/5 dark:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span>{t('composer.defaultWorkspaceOption')}</span>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{t('composer.defaultWorkspaceOption')}</span>
|
||||
{isDefaultWorkspacePath(workspacePath) && <Check className="h-3.5 w-3.5 shrink-0" />}
|
||||
</button>
|
||||
{workspaceOptions.map((option) => {
|
||||
const optionPath = normalizeWorkspacePath(option.path);
|
||||
if (!optionPath || isDefaultWorkspacePath(optionPath)) return null;
|
||||
const selected = optionPath === normalizeWorkspacePath(workspacePath);
|
||||
return (
|
||||
<button
|
||||
key={optionPath}
|
||||
type="button"
|
||||
data-testid={`chat-workspace-option-${encodeURIComponent(optionPath)}`}
|
||||
title={optionPath}
|
||||
aria-current={selected ? 'true' : undefined}
|
||||
onClick={() => handleSelectWorkspace(optionPath)}
|
||||
className={cn(
|
||||
'flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10',
|
||||
selected && 'bg-black/5 dark:bg-white/10',
|
||||
)}
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
{selected && <Check className="h-3.5 w-3.5 shrink-0" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="my-1 border-t border-black/5 dark:border-white/10" />
|
||||
<button
|
||||
type="button"
|
||||
data-testid="chat-workspace-choose-other"
|
||||
onClick={() => void handleChooseOtherWorkspace()}
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/5"
|
||||
className="flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-sm font-medium text-foreground transition-colors hover:bg-black/5 dark:hover:bg-white/10"
|
||||
>
|
||||
<FolderOpen className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span>{t('composer.chooseOtherWorkspaceOption')}</span>
|
||||
<FolderOpen className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1 truncate">{t('composer.chooseOtherWorkspaceOption')}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,13 +16,18 @@ import { useSettingsStore } from '@/stores/settings';
|
||||
import { ensureAcpChatSubscriptions, useAcpChatSessionStore } from '@/stores/acp-chat-session';
|
||||
import { LoadingSpinner } from '@/components/common/LoadingSpinner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { getWorkspaceDisplayLabel, resolveEffectiveWorkspace } from '@/lib/workspace-context';
|
||||
import {
|
||||
getWorkspaceDisplayLabel,
|
||||
isDefaultWorkspacePath,
|
||||
normalizeWorkspacePath,
|
||||
resolveEffectiveWorkspace,
|
||||
} from '@/lib/workspace-context';
|
||||
import { useStickToBottomInstant } from '@/hooks/use-stick-to-bottom-instant';
|
||||
import { getAcpUserMessageAnchorId } from '@/lib/acp/timeline-anchors';
|
||||
import type { MessageSegmentItem, RenderPart } from '@/lib/acp/timeline-types';
|
||||
import { projectOpenClawFileActivities, type AcpFileActivityProjection } from '@/lib/acp/openclaw-file-activities';
|
||||
import { hostApi } from '@/lib/host-api';
|
||||
import { ChatInput, type FileAttachment } from './ChatInput';
|
||||
import { ChatInput, type ChatWorkspaceOption, type FileAttachment } from './ChatInput';
|
||||
import { ChatToolbar } from './ChatToolbar';
|
||||
import { AcpTimeline } from './AcpTimeline';
|
||||
import { AcpErrorBanner } from './AcpErrorBanner';
|
||||
@@ -178,6 +183,7 @@ export function Chat() {
|
||||
const selectAcpSession = useChatStore((s) => s.selectAcpSession);
|
||||
const acknowledgeAcpSessionCreated = useChatStore((s) => s.acknowledgeAcpSessionCreated);
|
||||
const chatWorkspacePath = useSettingsStore((s) => s.chatWorkspacePath);
|
||||
const recentWorkspacePaths = useSettingsStore((s) => s.recentWorkspacePaths ?? []);
|
||||
const workspaceLabels = useSettingsStore((s) => s.workspaceLabels);
|
||||
const setChatWorkspacePath = useSettingsStore((s) => s.setChatWorkspacePath);
|
||||
const fetchAgents = useAgentsStore((s) => s.fetchAgents);
|
||||
@@ -202,6 +208,28 @@ export function Chat() {
|
||||
);
|
||||
const cwd = effectiveWorkspace.cwd;
|
||||
const workspaceLabel = getWorkspaceDisplayLabel(cwd, t('workspace.defaultLabel'), workspaceLabels);
|
||||
const workspaceOptions = useMemo<ChatWorkspaceOption[]>(() => {
|
||||
const seen = new Set<string>();
|
||||
const options: ChatWorkspaceOption[] = [];
|
||||
const candidatePaths = [
|
||||
...recentWorkspacePaths,
|
||||
chatWorkspacePath,
|
||||
...sessions.map((session) => session.workspacePath).filter((path): path is string => !!path),
|
||||
];
|
||||
for (const path of candidatePaths) {
|
||||
const normalized = normalizeWorkspacePath(path);
|
||||
if (!normalized || isDefaultWorkspacePath(normalized)) continue;
|
||||
const slashedPath = normalized.replace(/\\/g, '/');
|
||||
const identity = /^[A-Za-z]:\//.test(slashedPath) ? slashedPath.toLowerCase() : slashedPath;
|
||||
if (seen.has(identity)) continue;
|
||||
seen.add(identity);
|
||||
options.push({
|
||||
path: normalized,
|
||||
label: getWorkspaceDisplayLabel(normalized, t('workspace.defaultLabel'), workspaceLabels),
|
||||
});
|
||||
}
|
||||
return options;
|
||||
}, [chatWorkspacePath, recentWorkspacePaths, sessions, t, workspaceLabels]);
|
||||
const currentAgent = useMemo(
|
||||
() => (agents ?? []).find((agent) => agent.id === currentAgentId) ?? null,
|
||||
[agents, currentAgentId],
|
||||
@@ -540,6 +568,7 @@ export function Chat() {
|
||||
imageGenerating={imageGenerationPending}
|
||||
workspaceLabel={workspaceLabel}
|
||||
workspacePath={cwd}
|
||||
workspaceOptions={workspaceOptions}
|
||||
workspaceReadOnly={effectiveWorkspace.readOnly}
|
||||
onSelectWorkspace={setChatWorkspacePath}
|
||||
/>
|
||||
|
||||
+20
-5
@@ -7,6 +7,7 @@ import { create } from 'zustand';
|
||||
import { hostApi, type ChatSendWithMediaResult, type SessionLabelSummary } from '@/lib/host-api';
|
||||
import {
|
||||
isAcpWorkingDirectoryTruncatedTitle,
|
||||
isOpenClawSessionIdFallbackTitle,
|
||||
stripAcpWorkingDirectoryPrefix,
|
||||
} from '@shared/chat/session-title';
|
||||
import { useGatewayStore } from './gateway';
|
||||
@@ -164,8 +165,17 @@ type PendingOptimisticUserMessage = {
|
||||
|
||||
const _pendingOptimisticUserMessages = new Map<string, PendingOptimisticUserMessage[]>();
|
||||
|
||||
function isSyntheticSessionFallbackLabel(
|
||||
session: ChatSession | undefined,
|
||||
label: string | null | undefined,
|
||||
): boolean {
|
||||
return Boolean(session && label && isOpenClawSessionIdFallbackTitle(label, session.sessionId));
|
||||
}
|
||||
|
||||
function hasExplicitSessionLabel(session: ChatSession | undefined): boolean {
|
||||
return typeof session?.label === 'string' && Boolean(session.label.trim());
|
||||
return typeof session?.label === 'string'
|
||||
&& Boolean(session.label.trim())
|
||||
&& !isSyntheticSessionFallbackLabel(session, session.label);
|
||||
}
|
||||
|
||||
function applySessionBackendLabels(set: ChatSet, sessions: ChatSession[]): void {
|
||||
@@ -185,9 +195,12 @@ function applySessionBackendLabels(set: ChatSet, sessions: ChatSession[]): void
|
||||
}
|
||||
|
||||
const derivedTitle = session.derivedTitle || '';
|
||||
const label = toAutomaticSessionLabel(derivedTitle);
|
||||
if (!label && isAcpWorkingDirectoryTruncatedTitle(derivedTitle)) {
|
||||
if (nextLabels[session.key] === '…') {
|
||||
const derivedTitleUnavailable = isAcpWorkingDirectoryTruncatedTitle(derivedTitle)
|
||||
|| isSyntheticSessionFallbackLabel(session, derivedTitle);
|
||||
const label = derivedTitleUnavailable ? '' : toAutomaticSessionLabel(derivedTitle);
|
||||
if (!label && derivedTitleUnavailable) {
|
||||
const cachedLabel = nextLabels[session.key];
|
||||
if (cachedLabel === '…' || isSyntheticSessionFallbackLabel(session, cachedLabel)) {
|
||||
if (nextLabels === state.sessionLabels) nextLabels = { ...state.sessionLabels };
|
||||
delete nextLabels[session.key];
|
||||
}
|
||||
@@ -320,7 +333,8 @@ function applySessionLabelSummaries(
|
||||
// Only auto-hydrate missing labels. Existing entries include user renames
|
||||
// and must not be overwritten by transcript-derived titles.
|
||||
const existingLabel = nextLabels[summary.sessionKey]?.trim();
|
||||
if (labelText && !existingLabel && !hasExplicitSessionLabel(session)) {
|
||||
const existingLabelIsFallback = isSyntheticSessionFallbackLabel(session, existingLabel);
|
||||
if (labelText && (!existingLabel || existingLabelIsFallback) && !hasExplicitSessionLabel(session)) {
|
||||
if (nextLabels === state.sessionLabels) {
|
||||
nextLabels = { ...state.sessionLabels };
|
||||
}
|
||||
@@ -2804,6 +2818,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
const rawSessions = Array.isArray(data.sessions) ? data.sessions : [];
|
||||
const normalizedSessions: ChatSession[] = rawSessions.map((s: Record<string, unknown>) => ({
|
||||
key: String(s.key || ''),
|
||||
sessionId: s.sessionId ? String(s.sessionId) : undefined,
|
||||
label: s.label ? String(s.label) : undefined,
|
||||
displayName: s.displayName ? String(s.displayName) : undefined,
|
||||
derivedTitle: s.derivedTitle ? String(s.derivedTitle) : undefined,
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { isAcpWorkingDirectoryTruncatedTitle } from '@shared/chat/session-title';
|
||||
import {
|
||||
isAcpWorkingDirectoryTruncatedTitle,
|
||||
isOpenClawSessionIdFallbackTitle,
|
||||
} from '@shared/chat/session-title';
|
||||
import type { ChatSession } from './types';
|
||||
|
||||
export const LABEL_FETCH_CONCURRENCY = 5;
|
||||
@@ -44,7 +47,7 @@ export function isSessionLabelHydrationReady(runtimeKey: string, fallbackReady =
|
||||
}
|
||||
|
||||
export function getSessionLabelHydrationVersion(
|
||||
session: Pick<ChatSession, 'key' | 'updatedAt' | 'label' | 'displayName' | 'derivedTitle'>,
|
||||
session: Pick<ChatSession, 'key' | 'sessionId' | 'updatedAt' | 'label' | 'displayName' | 'derivedTitle'>,
|
||||
sessionLastActivity: Record<string, number>,
|
||||
): string {
|
||||
const activityVersion = session.updatedAt ?? sessionLastActivity[session.key] ?? 'none';
|
||||
@@ -53,7 +56,7 @@ export function getSessionLabelHydrationVersion(
|
||||
}
|
||||
|
||||
export function getSessionLabelHydrationCandidate(
|
||||
session: Pick<ChatSession, 'key' | 'updatedAt' | 'label' | 'displayName' | 'derivedTitle' | 'workspacePath' | 'createdLocally'>,
|
||||
session: Pick<ChatSession, 'key' | 'sessionId' | 'updatedAt' | 'label' | 'displayName' | 'derivedTitle' | 'workspacePath' | 'createdLocally'>,
|
||||
sessionLabels: Record<string, string>,
|
||||
sessionLastActivity: Record<string, number>,
|
||||
options: SessionLabelHydrationCandidateOptions = {},
|
||||
@@ -67,9 +70,14 @@ export function getSessionLabelHydrationCandidate(
|
||||
if (isLocalOrGhostMainSession) return null;
|
||||
if (isMainSession && (hasWorkspacePath || !options.includeWorkspacePath)) return null;
|
||||
|
||||
const hasSidebarLabel = normalizeLabelValue(sessionLabels[session.key]) != null;
|
||||
const explicitLabel = normalizeLabelValue(session.label);
|
||||
const sidebarLabel = normalizeLabelValue(sessionLabels[session.key]);
|
||||
const hasSidebarLabel = sidebarLabel != null
|
||||
&& !isOpenClawSessionIdFallbackTitle(sidebarLabel, session.sessionId);
|
||||
const explicitLabel = isOpenClawSessionIdFallbackTitle(session.label || '', session.sessionId)
|
||||
? null
|
||||
: normalizeLabelValue(session.label);
|
||||
const derivedTitle = isAcpWorkingDirectoryTruncatedTitle(session.derivedTitle || '')
|
||||
|| isOpenClawSessionIdFallbackTitle(session.derivedTitle || '', session.sessionId)
|
||||
? null
|
||||
: normalizeLabelValue(session.derivedTitle);
|
||||
const backendLabel = explicitLabel ?? derivedTitle;
|
||||
|
||||
@@ -97,6 +97,8 @@ type WorkspaceMockOptions = {
|
||||
workspaceLabels?: Record<string, string>;
|
||||
unavailableWorkspacePath?: string;
|
||||
sessionHistory?: Array<{ role: string; content: unknown; timestamp?: number }>;
|
||||
sessionId?: string;
|
||||
sessionLabel?: string;
|
||||
sessionDerivedTitle?: string | null;
|
||||
sessionSummaryFirstUserText?: string | null;
|
||||
};
|
||||
@@ -110,6 +112,10 @@ async function installWorkspaceMocks(app: ElectronApplication, options: Workspac
|
||||
SESSION_WORKSPACE,
|
||||
...recentWorkspacePaths.filter((path) => path !== SESSION_WORKSPACE),
|
||||
].slice(0, 10);
|
||||
const selectedGlobalRecentWorkspacePaths = [
|
||||
GLOBAL_WORKSPACE,
|
||||
...inheritedRecentWorkspacePaths.filter((path) => path !== GLOBAL_WORKSPACE),
|
||||
].slice(0, 10);
|
||||
const settingsSnapshot = {
|
||||
language: 'en',
|
||||
setupComplete: true,
|
||||
@@ -119,8 +125,10 @@ async function installWorkspaceMocks(app: ElectronApplication, options: Workspac
|
||||
};
|
||||
const sessionRow = {
|
||||
key: SESSION_KEY,
|
||||
displayName: 'Gateway session display name',
|
||||
displayName: options.sessionLabel ?? 'Gateway session display name',
|
||||
updatedAt: nowMs,
|
||||
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
||||
...(options.sessionLabel ? { label: options.sessionLabel } : {}),
|
||||
...(typeof options.sessionDerivedTitle === 'string' ? { derivedTitle: options.sessionDerivedTitle } : {}),
|
||||
};
|
||||
const sessionSummaries = {
|
||||
@@ -173,6 +181,12 @@ async function installWorkspaceMocks(app: ElectronApplication, options: Workspac
|
||||
recentWorkspacePaths: inheritedRecentWorkspacePaths,
|
||||
},
|
||||
}])]: { success: true },
|
||||
[stableStringify(['settings', 'setMany', {
|
||||
patch: {
|
||||
chatWorkspacePath: GLOBAL_WORKSPACE,
|
||||
recentWorkspacePaths: selectedGlobalRecentWorkspacePaths,
|
||||
},
|
||||
}])]: { success: true },
|
||||
[stableStringify(['settings', 'setMany', {
|
||||
patch: {
|
||||
chatWorkspacePath: options.chatWorkspacePath ?? DEFAULT_WORKSPACE,
|
||||
@@ -333,6 +347,87 @@ test.describe('ClawX chat workspace context', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('new chat workspace menu lists and switches to recent and known workspaces', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
try {
|
||||
await installWorkspaceMocks(app, {
|
||||
recentWorkspacePaths: [GLOBAL_WORKSPACE, DEFAULT_WORKSPACE],
|
||||
workspaceLabels: { [GLOBAL_WORKSPACE]: 'Global project' },
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const workspaceSelector = page.getByTestId('chat-workspace-selector');
|
||||
await expect(workspaceSelector).toHaveText(SESSION_WORKSPACE_LABEL, { timeout: 30_000 });
|
||||
await page.getByTestId('sidebar-new-chat').click();
|
||||
await expect(workspaceSelector).not.toHaveAttribute('aria-disabled', 'true');
|
||||
await workspaceSelector.click();
|
||||
|
||||
const workspaceMenu = page.getByTestId('chat-workspace-menu');
|
||||
await expect(workspaceMenu).toBeVisible();
|
||||
await expect(workspaceMenu.getByTestId('chat-workspace-default')).toBeVisible();
|
||||
await expect(workspaceMenu.getByTestId(
|
||||
`chat-workspace-option-${encodeURIComponent(SESSION_WORKSPACE)}`,
|
||||
)).toHaveText(SESSION_WORKSPACE_LABEL);
|
||||
const globalWorkspaceOption = workspaceMenu.getByTestId(
|
||||
`chat-workspace-option-${encodeURIComponent(GLOBAL_WORKSPACE)}`,
|
||||
);
|
||||
await expect(globalWorkspaceOption).toHaveText('Global project');
|
||||
await expect(workspaceMenu.getByTestId('chat-workspace-choose-other')).toBeVisible();
|
||||
|
||||
await globalWorkspaceOption.click();
|
||||
await expect(workspaceSelector).toHaveText('Global project');
|
||||
await expect(workspaceSelector).toHaveAttribute('title', GLOBAL_WORKSPACE);
|
||||
await expect.poll(async () => {
|
||||
const invocations = await getRecordedHostInvocations(app);
|
||||
return invocations.some((entry) => (
|
||||
entry.module === 'settings'
|
||||
&& entry.action === 'setMany'
|
||||
&& entry.payload?.patch?.chatWorkspacePath === GLOBAL_WORKSPACE
|
||||
));
|
||||
}).toBe(true);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('UUID-date fallback title is replaced by the first user prompt', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
const fallbackTitle = '72e4b28b (2026-07-22)';
|
||||
|
||||
try {
|
||||
await installWorkspaceMocks(app, {
|
||||
sessionId: '72e4b28b-8477-4e29-b57e-e14448fd42d0',
|
||||
sessionLabel: fallbackTitle,
|
||||
sessionDerivedTitle: fallbackTitle,
|
||||
sessionSummaryFirstUserText: '用浏览器打开B站',
|
||||
});
|
||||
|
||||
const page = await getStableWindow(app);
|
||||
try {
|
||||
await page.reload();
|
||||
} catch (error) {
|
||||
if (!String(error).includes('ERR_FILE_NOT_FOUND')) throw error;
|
||||
}
|
||||
|
||||
const workspaceGroup = page.getByTestId('sidebar').getByTestId(
|
||||
workspaceSessionGroupTestId(SESSION_WORKSPACE),
|
||||
);
|
||||
await expect(workspaceGroup).toContainText('用浏览器打开B站', { timeout: 30_000 });
|
||||
await expect(workspaceGroup).not.toContainText(fallbackTitle);
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test('host summary title stays clean when a derived title is unavailable', async ({ launchElectronApp }) => {
|
||||
const app = await launchElectronApp({ skipSetup: true });
|
||||
|
||||
|
||||
@@ -188,6 +188,74 @@ describe('chat store session label summary hydration', () => {
|
||||
expect(backgroundHistoryCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('replaces OpenClaw UUID-date fallback labels with the first user prompt', async () => {
|
||||
const sessionKey = 'agent:main:session-fallback';
|
||||
const sessionId = '72e4b28b-8477-4e29-b57e-e14448fd42d0';
|
||||
const fallbackTitle = '72e4b28b (2026-07-22)';
|
||||
gatewayRpcMock.mockImplementation(async (method: string) => {
|
||||
if (method === 'sessions.list') {
|
||||
return {
|
||||
sessions: [
|
||||
{
|
||||
key: sessionKey,
|
||||
sessionId,
|
||||
label: fallbackTitle,
|
||||
displayName: fallbackTitle,
|
||||
derivedTitle: fallbackTitle,
|
||||
updatedAt: 1_784_700_425_523,
|
||||
},
|
||||
{ key: 'agent:main:main', displayName: 'Main', updatedAt: 1_784_700_425_524 },
|
||||
],
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected gateway RPC: ${method}`);
|
||||
});
|
||||
hostApiFetchMock.mockImplementation(async (path: string) => {
|
||||
if (path === '/api/sessions/summaries') {
|
||||
return {
|
||||
success: true,
|
||||
summaries: [{
|
||||
sessionKey,
|
||||
firstUserText: '用浏览器打开B站',
|
||||
lastTimestamp: 1_784_700_425_523,
|
||||
workspacePath: '~/.openclaw/workspace',
|
||||
}],
|
||||
};
|
||||
}
|
||||
return { success: true, summaries: [] };
|
||||
});
|
||||
|
||||
const { useChatStore } = await import('@/stores/chat');
|
||||
useChatStore.setState({
|
||||
currentSessionKey: 'agent:main:main',
|
||||
currentAgentId: 'main',
|
||||
sessions: [],
|
||||
messages: [],
|
||||
sessionLabels: { [sessionKey]: fallbackTitle },
|
||||
sessionLastActivity: {},
|
||||
sending: false,
|
||||
activeRunId: null,
|
||||
streamingText: '',
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
pendingFinal: false,
|
||||
lastUserMessageAt: null,
|
||||
pendingToolImages: [],
|
||||
error: null,
|
||||
loading: false,
|
||||
thinkingLevel: null,
|
||||
runError: null,
|
||||
});
|
||||
|
||||
await useChatStore.getState().loadSessions();
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(useChatStore.getState().sessionLabels[sessionKey]).toBe('用浏览器打开B站');
|
||||
});
|
||||
expect(useChatStore.getState().sessions.find((session) => session.key === sessionKey)?.sessionId)
|
||||
.toBe(sessionId);
|
||||
});
|
||||
|
||||
it('strips ACP working-directory metadata from derived session titles', async () => {
|
||||
gatewayRpcMock.mockImplementation(async (method: string) => {
|
||||
if (method === 'sessions.list') {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isAcpWorkingDirectoryTruncatedTitle,
|
||||
isOpenClawSessionIdFallbackTitle,
|
||||
stripAcpWorkingDirectoryPrefix,
|
||||
} from '@shared/chat/session-title'
|
||||
|
||||
@@ -52,6 +53,20 @@ describe('stripAcpWorkingDirectoryPrefix', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('isOpenClawSessionIdFallbackTitle', () => {
|
||||
const sessionId = '72e4b28b-8477-4e29-b57e-e14448fd42d0'
|
||||
|
||||
it('identifies the UUID-prefix and date title generated for the same session', () => {
|
||||
expect(isOpenClawSessionIdFallbackTitle('72e4b28b (2026-07-22)', sessionId)).toBe(true)
|
||||
})
|
||||
|
||||
it('does not discard user labels or another session prefix', () => {
|
||||
expect(isOpenClawSessionIdFallbackTitle('用浏览器打开B站', sessionId)).toBe(false)
|
||||
expect(isOpenClawSessionIdFallbackTitle('9add3001 (2026-07-22)', sessionId)).toBe(false)
|
||||
expect(isOpenClawSessionIdFallbackTitle('72e4b28b (2026-07-22)', undefined)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAcpWorkingDirectoryTruncatedTitle', () => {
|
||||
it('identifies a cwd envelope truncated before the user prompt', () => {
|
||||
expect(
|
||||
|
||||
@@ -174,6 +174,20 @@ describe('sidebar session helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('does not persist a session rename when the title was not changed', async () => {
|
||||
const renameSession = vi.fn().mockResolvedValue(undefined);
|
||||
seedSidebarState(renameSession);
|
||||
renderSidebar();
|
||||
|
||||
fireEvent.click(screen.getByLabelText('Rename'));
|
||||
fireEvent.click(screen.getByLabelText('Save session title'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByLabelText('Session title')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(renameSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps rename controls active when focus moves to cancel and cancels on click', () => {
|
||||
const renameSession = vi.fn().mockResolvedValue(undefined);
|
||||
seedSidebarState(renameSession);
|
||||
|
||||
Reference in New Issue
Block a user