From 2abe824afdf1bb27a78029396bfc0e57bb3d07c4 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:46:12 +0800 Subject: [PATCH] fix --- README.ja-JP.md | 2 +- README.md | 2 +- README.zh-CN.md | 2 +- .../specs/rules/acp-chat-state-and-history.md | 2 +- .../gateway-backend-communication.md | 2 +- .../tasks/chat-session-reasoning-effort.md | 8 +- shared/chat/types.ts | 12 +++ src/pages/Chat/ChatInput.tsx | 33 ++++++-- src/stores/chat.ts | 45 +++++++++- src/stores/chat/session-catalog.ts | 1 + tests/e2e/chat-model-picker.spec.ts | 22 ++++- tests/unit/chat-input.test.tsx | 53 ++++++++++++ tests/unit/chat-load-sessions-startup.test.ts | 82 +++++++++++++++++++ 13 files changed, 248 insertions(+), 18 deletions(-) diff --git a/README.ja-JP.md b/README.ja-JP.md index 7b90081d..9847631c 100644 --- a/README.ja-JP.md +++ b/README.ja-JP.md @@ -106,7 +106,7 @@ ClawXは公式の**OpenClaw**コアを直接ベースに構築されています `@agent` で別のエージェントを選ぶと、ClawX はデフォルトエージェントを経由せず、そのエージェント自身の会話コンテキストへ直接切り替えます。各エージェントのワークスペースは既定で分離されていますが、より強い実行時分離は OpenClaw の sandbox 設定に依存します。 セッション側欄はワークスペース優先で整理され、既定ワークスペースを先頭に固定し、その他のワークスペースは自然順に並べます。各ワークスペースは折りたたみや追加読み込みができます。AI の返信中は行にスピナーが表示され、未確認の返信が完了すると青い点に変わり、会話を開くと相対アクティビティ時刻に戻ります。ホバーすると引き続き操作ボタンが表示されます。インポートしたワークスペースは側欄の見出しから名前を変更でき、新しい名前はチャット入力欄の下にも反映されます。見出しにホバーすると引き続きファイルシステムのパスを確認できます。選択中の会話に有効なワークスペースがある場合、新しいチャットはそれを引き継ぎ、最初の送信までは変更できます。編集可能な新規または未バインドのチャットでは、コンポーザーのワークスペースチップから最近使用したワークスペースと既存セッションのワークスペースの一覧を開き、既定ワークスペースへ戻すか別フォルダーを選べます。保存済みのワークスペースフォルダーが移動または削除されている場合、Chat はセッション作成を一時停止し、無効なパスを繰り返し再試行せずに既存のフォルダーを選ぶよう案内します。利用できない既定以外のグループには側欄で印が付き、確認後に削除できます。この操作ではグループ内の全セッションが完全に削除されます。セッション行の削除と画面遷移は完全削除が成功した後にのみ行われ、失敗した場合は会話と確認ダイアログが保持されるため再試行できます。OpenClaw が生成する UUID と日付のフォールバックタイトルは、そのセッション ID と一致する場合に限って欠落タイトルとして扱い、セッション名として保存せず、会話の最初のユーザーメッセージに置き換えて表示します。 各 Agent は `provider/model` の実行時設定を個別に上書きできます。上書きしていない Agent は引き続きグローバルの既定モデルを継承します。 -コンポーザーのモデルメニューでは、現在のモデルについて OpenClaw が公開する推論強度を専用サブメニューに表示します。選択した強度は現在のセッションに適用され、「思考」トグルは明示的な Off と以前の選択またはランタイム既定レベルを切り替えます。 +コンポーザーのモデルメニューでは、現在のモデルについて OpenClaw が公開する推論強度を専用サブメニューに表示します。カスタムモデルで新しいチャットを作成した場合も、最初の送信前は Gateway が公開する現在の Agent の既定値を使ってこのコントロールを表示し、セッション作成後はセッションメタデータを優先します。選択した強度は現在のセッションに適用され、「思考」トグルは明示的な Off と以前の選択または利用可能な有効レベルを切り替えます。 Chat の右パネルにあるワークスペースとプレビューの各タブでは、Markdown、`.docx`、`.pptx` ファイルを読み取り専用でプレビューできます。Markdown ファイルのプレビューでは、同じシンタックスハイライト、ソフトラップ、コピー操作付きのフェンスコード、CJK 対応の解析、KaTeX 数式を静的レンダリングモードでサポートします。プレビューのヘッダーから選択中のファイルを ClawX の表示領域全体に拡大でき、同じボタンまたは Esc で右パネルへ戻れます。従来形式の `.doc` と `.ppt` はアプリ内ではプレビューせず、引き続き OS 経由で開きます。DOCX のページ区切りは Microsoft Word と異なる場合があり、PPTX プレビューではアニメーション、画面切り替え、メディア再生をサポートしません。20 MB を超える Office ファイルはアプリ内でプレビューされません。 diff --git a/README.md b/README.md index facb8772..5fb4d2ed 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Skills you insert from the composer appear as `/skill-name` chips; click a chip 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, and each workspace can collapse or load more sessions. A row shows a spinner while the AI is replying, a blue dot when an unseen reply finishes, and its relative activity time after the conversation is opened; hovering still reveals row 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. When available, a new chat inherits the selected conversation's workspace while remaining editable until first send. Editable new or unbound chats expose the composer workspace chip as a small menu that lists recent and known-session 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. A session row is removed and navigation changes only after permanent deletion succeeds; failed deletions leave the conversation and confirmation open for retry. Synthetic OpenClaw UUID-date fallback titles are treated as missing only when they match the session ID, then replaced with the conversation's first user prompt instead of being persisted as the session name. Each agent can also override its own `provider/model` runtime setting; agents without overrides continue inheriting the global default model. -The composer model menu opens reasoning effort in a dedicated submenu using only levels advertised by OpenClaw for the current model. A selected level applies to the current session, and the Thinking toggle switches between explicit Off and the prior or runtime-default level. +The composer model menu opens reasoning effort in a dedicated submenu using only levels advertised by OpenClaw for the current model. This control is also available on a new custom-model chat before its first message, using the current agent defaults advertised by Gateway until the session is created. A selected level applies to the current session, and the Thinking toggle switches between explicit Off and the prior or an available enabled level. The Workspace and Preview tabs in Chat's right panel provide read-only previews for Markdown, `.docx`, and `.pptx` files. Markdown file previews use the same syntax-highlighted, soft-wrapped, copyable fenced code, CJK-aware parsing, and KaTeX math support in static rendering mode. The Preview header can expand the selected file to the full ClawX viewport; use the same control or Escape to return to the panel. Legacy `.doc` and `.ppt` files continue to open through the operating system instead of inline. DOCX pagination may differ from Microsoft Word, and PPTX previews do not support animations, transitions, or media playback. Office files larger than 20 MB are not previewed inline. diff --git a/README.zh-CN.md b/README.zh-CN.md index 8c5946fe..3d3c0af5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -107,7 +107,7 @@ ClawX 直接基于官方 **OpenClaw** 核心构建。无需单独安装,我们 当你使用 `@agent` 选择其他智能体时,ClawX 会直接切换到该智能体自己的对话上下文,而不是经过默认智能体转发。各 Agent 工作区默认彼此分离,但更强的运行时隔离仍取决于 OpenClaw 的 sandbox 配置。 会话侧边栏现在以工作空间优先组织:默认工作空间固定在最上方,其它工作空间按自然顺序排列,每个工作空间都可折叠或继续加载更多会话。AI 回复期间,会话行显示加载指示器;未查看的回复完成后显示蓝点;打开会话后恢复显示相对活跃时间,悬停时仍会露出操作按钮。导入的工作空间可从侧边栏标题处重命名,新名称会同步显示在对话输入框下方,同时悬浮标题仍可查看文件系统路径。如果当前所选会话存在有效工作空间,新对话会继承该工作空间,并在首次发送前保持可编辑。对于可编辑的新对话或未绑定对话,输入框的工作空间卡片会打开一个小菜单,列出最近使用及现有会话中的工作空间,并可切回默认工作空间或选择其它目录。如果保存的工作空间文件夹已被移动或删除,Chat 会暂停创建会话并提示选择现有文件夹,而不会持续重试失效路径。不可用的非默认工作空间会在侧边栏显示标记,并可在确认后删除;该操作会永久删除分组中的全部会话。只有永久删除成功后,会话行才会移除且页面才会跳转;删除失败时会保留会话与确认框,方便重试。OpenClaw 生成的 UUID 加日期兜底标题只有在与该会话 ID 匹配时才会被视为缺失标题,随后改用会话的首条用户消息展示,而不会被持久化为会话名称。 每个 Agent 还可以单独覆盖自己的 `provider/model` 运行时设置;未覆盖的 Agent 会继续继承全局默认模型。 -输入框的模型菜单会在独立子菜单中显示 OpenClaw 针对当前模型提供的推理强度。所选强度仅对当前会话持续生效;“思考”开关则在显式关闭与此前选择或运行时默认档位之间切换。 +输入框的模型菜单会在独立子菜单中显示 OpenClaw 针对当前模型提供的推理强度。使用自定义模型新建对话时,首次发送前也会根据 Gateway 提供的当前 Agent 默认值显示该控件;会话创建后则以会话元数据为准。所选强度仅对当前会话持续生效;“思考”开关则在显式关闭与此前选择或可用的启用档位之间切换。 Chat 右侧面板的工作空间和预览选项卡支持以只读方式预览 Markdown、`.docx` 和 `.pptx` 文件。Markdown 文件预览以静态渲染模式提供相同的围栏代码语法高亮、软换行与复制操作、面向中日韩文本的解析和 KaTeX 数学公式支持。预览栏顶部可将当前文件展开至 ClawX 的整个可视区域;再次点击该按钮或按 Esc 即可返回侧栏。旧版 `.doc` 和 `.ppt` 文件不会在应用内预览,而是继续通过操作系统打开。DOCX 的分页效果可能与 Microsoft Word 不同;PPTX 预览不支持动画、切换效果或媒体播放。超过 20 MB 的 Office 文件不会在应用内预览。 diff --git a/harness/specs/rules/acp-chat-state-and-history.md b/harness/specs/rules/acp-chat-state-and-history.md index 3e9347b2..61334e6d 100644 --- a/harness/specs/rules/acp-chat-state-and-history.md +++ b/harness/specs/rules/acp-chat-state-and-history.md @@ -10,7 +10,7 @@ appliesTo: Main owns ACP process, SDK, routing lifecycle, and serialization of operations on the shared ACP connection; Renderer owns semantic reduction into an in-memory timeline. Notifications emitted during `session/load` are returned as one generation-scoped raw batch and reduced in one Renderer state commit. Renderer may temporarily buffer matching host events during the IPC result handoff, while each ordinary live prompt update continues through host events and is applied immediately without a Renderer batching timer. A pending prompt may retain a bounded Main routing context and Renderer timeline snapshot so navigation cannot drop its stream; those contexts must be keyed by session and generation, remain memory-only, and be released when the prompt settles. Permission requests are interactive only for an active prompt. Stale session generations are ignored, and ClawX does not persist a second ACP ledger or reduced Chat history. -Current-session reasoning effort is Gateway session metadata, not ACP timeline state. The Chat picker must use the selected row's `thinkingLevels` as the supported option set, `thinkingDefault` as the effective fallback, and `thinkingLevel` as the explicit override. Its effort submenu lists concrete levels only (no inherited option) and may expose a Thinking toggle only when `off` is advertised: disabling patches explicit `off`, while enabling restores the prior non-off override or the runtime default. Updates go through `sessions.patch`; `null` clears the override while `off` remains an explicit value. Renderer must not derive support from provider or model-name patterns, and sending must wait until an in-flight picker patch settles. +Current-session reasoning effort is Gateway session metadata, not ACP timeline state. The Chat picker must use the selected row's `thinkingLevels` as the supported option set, `thinkingDefault` as the effective fallback, and `thinkingLevel` as the explicit override. For a Renderer-local draft that Gateway has not listed yet, it may temporarily use the agent-scoped `sessions.list.defaults` thinking metadata returned by the same catalog request; persisted rows take precedence as soon as they exist. Its effort submenu lists concrete levels only (no inherited option) and may expose a Thinking toggle only when `off` is advertised: disabling patches explicit `off`, while enabling restores the prior non-off override, a non-off runtime default, or an advertised enabled level (preferring `medium`) when the runtime default is also `off`. Updates go through `sessions.patch`; `null` clears the override while `off` remains an explicit value. Renderer must not derive support from provider or model-name patterns, and sending must wait until an in-flight picker patch settles. ACP replay is the primary history authority. The only approved transcript-derived content supplements are best-effort recovery of asynchronous image-generation completions with proven `image_generate` context and recovery of explicit line-leading assistant OpenClaw `MEDIA:` attachment directives omitted by ACP. The general attachment exception does not require image-generation context, but it recovers only attachment references. When ACP replay for a cron session is completely empty, scheduled-task prompt and completion summaries may instead come from Main's typed cron-history host API. This cron exception must be anchored by Gateway `cron.runs` (with a Main-owned legacy file fallback), be generation-scoped and in memory, and never replace or duplicate non-empty ACP replay. When an anchored run summary carries OpenClaw's bounded-summary ellipsis, Main may recover that run's final assistant text from the identified run transcript only when it is longer and shares the complete persisted summary prefix; missing, mismatched, or unbounded summaries remain unchanged. A separate metadata-only supplement may annotate an ACP-replayed assistant turn with whole-turn duration because ACP `session/load` omits the original event timestamps; it cannot create turns or content. These exceptions remain marked and in memory; do not generalize them to bare paths, surrounding transcript prose, arbitrary ordinary messages, tool cards, plans, permissions, thoughts, file activity, or any parallel persisted history. diff --git a/harness/specs/scenarios/gateway-backend-communication.md b/harness/specs/scenarios/gateway-backend-communication.md index 5725aa7d..dd6aedab 100644 --- a/harness/specs/scenarios/gateway-backend-communication.md +++ b/harness/specs/scenarios/gateway-backend-communication.md @@ -95,4 +95,4 @@ The local HTML Preview privileged bridge is also Main-owned: Renderer may load a Gateway session-catalog subscription, normalization, ordered list/event replay, attention transitions, and reconnect recovery are documented in `harness/reference/sidebar-session-attention.md`. -Gateway session rows are also authoritative for Chat reasoning-effort controls. Renderer may project `thinkingLevel`, `thinkingLevels`, and `thinkingDefault` into the session catalog and may update the explicit current-session override only through the Main-owned `sessions.patch` RPC boundary. Renderer must not invent thinking levels from model names. Custom-provider primary models are synced with a fixed OpenClaw reasoning ladder so Gateway can advertise levels to Chat; provider settings do not expose enable-reasoning controls. +Gateway session rows are also authoritative for Chat reasoning-effort controls. Renderer may project `thinkingLevel`, `thinkingLevels`, and `thinkingDefault` into the session catalog and may update the explicit current-session override only through the Main-owned `sessions.patch` RPC boundary. A local draft missing from the catalog may use only the agent-scoped `sessions.list.defaults` thinking metadata from that request until Gateway returns a persisted row. Renderer must not invent thinking levels from model names. Custom-provider primary models are synced with a fixed OpenClaw reasoning ladder so Gateway can advertise levels to Chat; provider settings do not expose enable-reasoning controls. diff --git a/harness/specs/tasks/chat-session-reasoning-effort.md b/harness/specs/tasks/chat-session-reasoning-effort.md index fbd04bd5..de9da157 100644 --- a/harness/specs/tasks/chat-session-reasoning-effort.md +++ b/harness/specs/tasks/chat-session-reasoning-effort.md @@ -27,6 +27,8 @@ touchedAreas: - tests/unit/openclaw-auth.test.ts - tests/unit/chat-input.test.tsx - tests/unit/chat-store-session-label-fetch.test.ts + - tests/unit/chat-load-sessions-startup.test.ts + - tests/unit/gateway-events.test.ts - tests/unit/provider-runtime-sync.test.ts - tests/unit/provider-service-stale-cleanup.test.ts - tests/e2e/chat-model-picker.spec.ts @@ -37,9 +39,10 @@ touchedAreas: expectedUserBehavior: - The Chat model button shows the effective reasoning effort for the current session. - The model menu offers only the thinking levels advertised by OpenClaw for the resolved model. + - A new local draft can use the current agent's Gateway-advertised session defaults before its first prompt creates a persisted session row. - Reasoning effort opens in a dedicated submenu with a current-session Thinking toggle. - Selecting a level persists an explicit current-session override through Gateway sessions.patch. - - Disabling Thinking patches the explicit off level; enabling it restores the prior selection or runtime default. + - Disabling Thinking patches the explicit off level; enabling it restores the prior selection, a non-off runtime default, or an advertised enabled level when the runtime default is off. - A message cannot be sent while an effort change is still being applied. requiredProfiles: - fast @@ -59,6 +62,8 @@ requiredTests: - tests/unit/openclaw-auth.test.ts - tests/unit/chat-input.test.tsx - tests/unit/chat-store-session-label-fetch.test.ts + - tests/unit/chat-load-sessions-startup.test.ts + - tests/unit/gateway-events.test.ts - tests/unit/provider-runtime-sync.test.ts - tests/unit/provider-service-stale-cleanup.test.ts - tests/e2e/chat-model-picker.spec.ts @@ -66,6 +71,7 @@ requiredTests: acceptance: - Renderer uses the typed host-api Gateway RPC boundary and never opens its own Gateway transport. - thinkingLevels, thinkingDefault, and thinkingLevel remain Gateway-owned session metadata. + - A local draft falls back only to agent-scoped sessions.list defaults, and a persisted session row always takes precedence. - Custom-provider primary models are synced with a fixed OpenClaw reasoning ladder; provider settings do not expose enable-reasoning controls. - Explicit off is distinct from a cleared override. - Failed patches restore the prior session state and leave the message available to send. diff --git a/shared/chat/types.ts b/shared/chat/types.ts index 7a7e1001..61b0199b 100644 --- a/shared/chat/types.ts +++ b/shared/chat/types.ts @@ -86,6 +86,15 @@ export interface ThinkingLevelOption { label: string; } +/** Agent-scoped defaults advertised by Gateway sessions.list. */ +export interface ChatThinkingDefaults { + agentId: string; + modelProvider?: string; + model?: string; + thinkingLevels?: ThinkingLevelOption[]; + thinkingDefault?: string; +} + /** Session from sessions.list */ export interface ChatSession { key: string; @@ -101,6 +110,7 @@ export interface ChatSession { thinkingLevels?: ThinkingLevelOption[]; /** Effective inherited value when no explicit session override is present. */ thinkingDefault?: string; + modelProvider?: string; model?: string; updatedAt?: number; status?: string; @@ -148,6 +158,8 @@ export interface ChatState { /** Last message timestamp (ms) per session key, used for sorting */ sessionLastActivity: Record; + /** Agent-scoped fallback metadata for a local draft not yet listed by Gateway. */ + thinkingDefaults: ChatThinkingDefaults | null; /** Session currently persisting a reasoning-effort override. */ thinkingLevelUpdatingSessionKey: string | null; diff --git a/src/pages/Chat/ChatInput.tsx b/src/pages/Chat/ChatInput.tsx index 1abd6e59..ba44c628 100644 --- a/src/pages/Chat/ChatInput.tsx +++ b/src/pages/Chat/ChatInput.tsx @@ -246,6 +246,7 @@ export function ChatInput({ const currentAgentId = useChatStore((s) => s.currentAgentId); const currentSessionKey = useChatStore((s) => s.currentSessionKey); const sessions = useChatStore((s) => s.sessions); + const thinkingDefaults = useChatStore((s) => s.thinkingDefaults); const thinkingLevelUpdatingSessionKey = useChatStore((s) => s.thinkingLevelUpdatingSessionKey); const updateSessionThinkingLevel = useChatStore((s) => s.updateSessionThinkingLevel); const currentAgent = useMemo( @@ -286,16 +287,27 @@ export function ChatInput({ () => sessions.find((session) => session.key === currentSessionKey), [currentSessionKey, sessions], ); - const thinkingOptions = useMemo( - () => canConfigureThinking ? (currentSession?.thinkingLevels ?? []) : [], - [canConfigureThinking, currentSession?.thinkingLevels], - ); + const canUseThinkingDefaults = currentSession?.createdLocally === true + && thinkingDefaults?.agentId === currentAgentId; + const thinkingOptions = useMemo(() => { + if (!canConfigureThinking) return []; + if (currentSession?.thinkingLevels?.length) return currentSession.thinkingLevels; + return canUseThinkingDefaults ? (thinkingDefaults?.thinkingLevels ?? []) : []; + }, [ + canConfigureThinking, + canUseThinkingDefaults, + currentSession?.thinkingLevels, + thinkingDefaults?.thinkingLevels, + ]); const effortOptions = useMemo( () => thinkingOptions.filter((option) => option.id !== 'off' && option.id !== 'minimal'), [thinkingOptions], ); - const supportsThinkingToggle = thinkingOptions.some((option) => option.id === 'off'); - const effectiveThinkingLevel = currentSession?.thinkingLevel ?? currentSession?.thinkingDefault; + const supportsThinkingToggle = thinkingOptions.some((option) => option.id === 'off') + && effortOptions.length > 0; + const effectiveThinkingLevel = currentSession?.thinkingLevel + ?? currentSession?.thinkingDefault + ?? (canUseThinkingDefaults ? thinkingDefaults?.thinkingDefault : undefined); const thinkingEnabled = effectiveThinkingLevel !== 'off'; const currentThinkingLabel = useMemo(() => { if (!canConfigureThinking || !effectiveThinkingLevel) { @@ -605,16 +617,23 @@ export function ChatInput({ void handleSelectThinkingLevel('off'); return; } + const runtimeDefault = currentSession?.thinkingDefault + ?? (canUseThinkingDefaults ? thinkingDefaults?.thinkingDefault : undefined); void handleSelectThinkingLevel( lastEnabledThinkingLevelRef.current - ?? currentSession?.thinkingDefault + ?? (runtimeDefault && runtimeDefault !== 'off' ? runtimeDefault : null) + ?? effortOptions.find((option) => option.id === 'medium')?.id + ?? effortOptions[0]?.id ?? null, ); }, [ + canUseThinkingDefaults, currentSession?.thinkingDefault, currentSession?.thinkingLevel, effectiveThinkingLevel, + effortOptions, handleSelectThinkingLevel, + thinkingDefaults?.thinkingDefault, ]); const handleWorkspaceButtonClick = useCallback(() => { diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 191ae758..f92c7aa0 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -487,12 +487,20 @@ async function fetchChatSessionsList(): Promise> { }); } +async function fetchAgentThinkingDefaults(agentId: string): Promise> { + return useGatewayStore.getState().rpc>('sessions.list', { + agentId, + limit: 1, + }); +} + export const useChatStore = create((set, get) => ({ sessions: [], currentSessionKey: DEFAULT_SESSION_KEY, currentAgentId: 'main', sessionLabels: {}, sessionLastActivity: {}, + thinkingDefaults: null, thinkingLevelUpdatingSessionKey: null, loadSessions: async (options) => { @@ -548,8 +556,42 @@ export const useChatStore = create((set, get) => ({ try { const localRevisionBeforeRequest = localSessionCatalogRevision; - const data = await fetchChatSessionsList(); + const stateBeforeRequest = get(); + const requestedAgentId = stateBeforeRequest.currentAgentId; + const selectedSessionBeforeRequest = stateBeforeRequest.sessions.find( + (session) => session.key === stateBeforeRequest.currentSessionKey, + ); + const shouldFetchScopedDefaults = requestedAgentId !== 'main' + && selectedSessionBeforeRequest?.createdLocally === true; + const [data, scopedDefaultsData] = await Promise.all([ + fetchChatSessionsList(), + shouldFetchScopedDefaults ? fetchAgentThinkingDefaults(requestedAgentId) : null, + ]); + const defaultsData = scopedDefaultsData ?? data; if (generation === sessionCatalogGeneration) { + const rawDefaults = defaultsData.defaults + && typeof defaultsData.defaults === 'object' + && !Array.isArray(defaultsData.defaults) + ? defaultsData.defaults as Record + : null; + const normalizedDefaults = rawDefaults + ? normalizeGatewaySessionRow({ ...rawDefaults, key: '__gateway_defaults__' }) + : null; + const thinkingDefaults = normalizedDefaults + ? { + agentId: requestedAgentId, + ...(normalizedDefaults.modelProvider + ? { modelProvider: normalizedDefaults.modelProvider } + : {}), + ...(normalizedDefaults.model ? { model: normalizedDefaults.model } : {}), + ...(normalizedDefaults.thinkingLevels + ? { thinkingLevels: normalizedDefaults.thinkingLevels } + : {}), + ...(normalizedDefaults.thinkingDefault + ? { thinkingDefault: normalizedDefaults.thinkingDefault } + : {}), + } + : null; const rawSessions = Array.isArray(data.sessions) ? data.sessions : []; const normalizedSessions = rawSessions.map((session) => ( normalizeGatewaySessionRow(session as Record) @@ -921,6 +963,7 @@ export const useChatStore = create((set, get) => ({ currentAgentId: getAgentIdFromSessionKey(nextSessionKey), sessionLabels, sessionLastActivity, + thinkingDefaults, }; }); applySessionBackendLabels(set, sessionsWithCurrent); diff --git a/src/stores/chat/session-catalog.ts b/src/stores/chat/session-catalog.ts index 550b4695..135d745d 100644 --- a/src/stores/chat/session-catalog.ts +++ b/src/stores/chat/session-catalog.ts @@ -21,6 +21,7 @@ const STRING_FIELDS = [ 'lastMessagePreview', 'thinkingLevel', 'thinkingDefault', + 'modelProvider', 'model', 'workspacePath', ] as const satisfies readonly SessionField[]; diff --git a/tests/e2e/chat-model-picker.spec.ts b/tests/e2e/chat-model-picker.spec.ts index 5cd766b4..2180307a 100644 --- a/tests/e2e/chat-model-picker.spec.ts +++ b/tests/e2e/chat-model-picker.spec.ts @@ -13,6 +13,7 @@ test.describe('ClawX chat model picker', () => { let currentModelRef = refs.alphaModelRef; let currentThinkingLevel: string | null = null; + let thinkingSessionMaterialized = false; const thinkingLevels = [ { id: 'off', label: 'Off' }, { id: 'low', label: 'Low' }, @@ -68,13 +69,19 @@ test.describe('ClawX chat model picker', () => { return { success: true, result: { - sessions: [{ + defaults: { + modelProvider: currentModelRef.split('/')[0], + model: currentModelRef.split('/').slice(1).join('/'), + thinkingDefault: 'medium', + thinkingLevels, + }, + sessions: thinkingSessionMaterialized ? [{ key: 'agent:main:main', displayName: 'main', thinkingLevel: currentThinkingLevel, thinkingDefault: 'medium', thinkingLevels, - }], + }] : [], }, }; } @@ -123,18 +130,25 @@ test.describe('ClawX chat model picker', () => { hostRequests.push({ path: `gateway:${method}`, method: 'RPC', body: params }); if (method === 'sessions.list') { return makeResponse(request.id, { - sessions: [{ + defaults: { + modelProvider: currentModelRef.split('/')[0], + model: currentModelRef.split('/').slice(1).join('/'), + thinkingDefault: 'medium', + thinkingLevels, + }, + sessions: thinkingSessionMaterialized ? [{ key: 'agent:main:main', displayName: 'main', thinkingLevel: currentThinkingLevel, thinkingDefault: 'medium', thinkingLevels, - }], + }] : [], }); } if (method === 'sessions.patch') { const patch = params as { thinkingLevel?: string | null }; currentThinkingLevel = patch.thinkingLevel ?? null; + thinkingSessionMaterialized = true; return makeResponse(request.id, { ok: true, key: 'agent:main:main', diff --git a/tests/unit/chat-input.test.tsx b/tests/unit/chat-input.test.tsx index e634ac4d..5a86012e 100644 --- a/tests/unit/chat-input.test.tsx +++ b/tests/unit/chat-input.test.tsx @@ -15,6 +15,7 @@ const { agentsState, chatState, gatewayState, providersState, artifactPanelMocks currentAgentId: 'main', currentSessionKey: 'agent:main:main', sessions: [] as Array>, + thinkingDefaults: null as Record | null, thinkingLevelUpdatingSessionKey: null as string | null, updateSessionThinkingLevel: vi.fn(), }, @@ -246,6 +247,7 @@ describe('ChatInput agent targeting', () => { chatState.currentAgentId = 'main'; chatState.currentSessionKey = 'agent:main:main'; chatState.sessions = []; + chatState.thinkingDefaults = null; chatState.thinkingLevelUpdatingSessionKey = null; chatState.updateSessionThinkingLevel.mockReset(); gatewayState.status = { state: 'running', port: 18789 }; @@ -740,6 +742,32 @@ describe('ChatInput agent targeting', () => { }); }); + it('shows Gateway agent defaults for a custom-model draft not yet listed as a session', () => { + configureAgentAndModelPickers(); + chatState.sessions = [{ + key: chatState.currentSessionKey, + createdLocally: true, + }]; + chatState.thinkingDefaults = { + agentId: 'main', + modelProvider: 'custom-aaaaaaaa', + model: 'gpt-a', + thinkingDefault: 'medium', + thinkingLevels: [ + { id: 'off', label: 'Off' }, + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + ], + }; + + renderChatInput(); + + expect(screen.getByTestId('chat-model-picker-button')).toHaveTextContent('gpt-a (Alpha) · Medium'); + fireEvent.click(screen.getByTestId('chat-model-picker-button')); + expect(screen.getByTestId('chat-reasoning-effort-menu-trigger')).toBeInTheDocument(); + }); + it('does not expose reasoning controls for a non-custom model', () => { configureAgentAndModelPickers(); const now = '2025-01-01T00:00:00.000Z'; @@ -849,6 +877,31 @@ describe('ChatInput agent targeting', () => { }); }); + it('enables thinking at medium when the runtime default is off', async () => { + configureAgentAndModelPickers(); + chatState.sessions = [{ + key: chatState.currentSessionKey, + thinkingDefault: 'off', + thinkingLevels: [ + { id: 'off', label: 'Off' }, + { id: 'low', label: 'Low' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + ], + }]; + chatState.updateSessionThinkingLevel.mockResolvedValue(undefined); + + renderChatInput(); + fireEvent.click(screen.getByTestId('chat-model-picker-button')); + fireEvent.click(screen.getByTestId('chat-reasoning-effort-menu-trigger')); + expect(screen.getByTestId('chat-thinking-toggle')).not.toBeChecked(); + fireEvent.click(screen.getByTestId('chat-thinking-toggle')); + + await waitFor(() => { + expect(chatState.updateSessionThinkingLevel).toHaveBeenCalledWith('agent:main:main', 'medium'); + }); + }); + it('closes the focused skill picker search with Escape', async () => { configureAgentAndModelPickers(); diff --git a/tests/unit/chat-load-sessions-startup.test.ts b/tests/unit/chat-load-sessions-startup.test.ts index 435bb919..35901fda 100644 --- a/tests/unit/chat-load-sessions-startup.test.ts +++ b/tests/unit/chat-load-sessions-startup.test.ts @@ -113,6 +113,88 @@ describe('chat session catalog startup', () => { }); }); + it('retains agent-scoped thinking defaults for a local draft session', async () => { + gatewayRpcMock.mockResolvedValue({ + ts: 1, + defaults: { + modelProvider: 'custom-aaaaaaaa', + model: 'gpt-a', + thinkingDefault: 'medium', + thinkingLevels: [ + { id: 'off', label: 'Off' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + ], + }, + sessions: [], + }); + + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:main:session-draft', + currentAgentId: 'main', + sessions: [{ key: 'agent:main:session-draft', createdLocally: true }], + }); + + await useChatStore.getState().loadSessions({ force: true }); + + expect(useChatStore.getState().thinkingDefaults).toEqual({ + agentId: 'main', + modelProvider: 'custom-aaaaaaaa', + model: 'gpt-a', + thinkingDefault: 'medium', + thinkingLevels: [ + { id: 'off', label: 'Off' }, + { id: 'medium', label: 'Medium' }, + { id: 'high', label: 'High' }, + ], + }); + }); + + it('loads non-main draft defaults without scoping the sidebar catalog request', async () => { + gatewayRpcMock.mockImplementation(async (_method: string, params?: Record) => { + if (params?.agentId === 'research') { + return { + defaults: { + modelProvider: 'custom-research', + model: 'reasoner', + thinkingDefault: 'high', + thinkingLevels: [ + { id: 'off', label: 'Off' }, + { id: 'high', label: 'High' }, + ], + }, + sessions: [], + }; + } + return { ts: 1, sessions: [] }; + }); + + const { useChatStore } = await import('@/stores/chat'); + useChatStore.setState({ + currentSessionKey: 'agent:research:session-draft', + currentAgentId: 'research', + sessions: [{ key: 'agent:research:session-draft', createdLocally: true }], + }); + + await useChatStore.getState().loadSessions({ force: true }); + + expect(gatewayRpcMock).toHaveBeenNthCalledWith(1, 'sessions.list', { + includeDerivedTitles: true, + includeLastMessage: true, + }); + expect(gatewayRpcMock).toHaveBeenNthCalledWith(2, 'sessions.list', { + agentId: 'research', + limit: 1, + }); + expect(useChatStore.getState().thinkingDefaults).toEqual(expect.objectContaining({ + agentId: 'research', + modelProvider: 'custom-research', + model: 'reasoner', + thinkingDefault: 'high', + })); + }); + it('hydrates workspace identity and title activity from session summaries', async () => { gatewayRpcMock.mockResolvedValue({ ts: 1,