From 4d4dc4fff3f7481fb5f2b755120a1e73f1b8017c Mon Sep 17 00:00:00 2001 From: hcyang Date: Wed, 13 May 2026 17:26:35 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(ui):=20=E6=96=B0=E5=A2=9E=E9=80=9A?= =?UTF-8?q?=E7=94=A8DropdownMenu=E7=BB=84=E4=BB=B6=E5=B9=B6=E6=9B=BF?= =?UTF-8?q?=E6=8D=A2=E6=8A=80=E8=83=BD=E4=B8=8E=E6=A8=A1=E5=9E=8B=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增DropdownMenu组件,支持滚动、选中指示与自定义渲染 - 使技能选择区使用DropdownMenu替代手写列表,统一样式与行为 - 模型选择区同样改用DropdownMenu组件,简化代码 - 添加DropdownMenu相关类型定义和滚动窗口计算函数 - 优化PromptInput中颜色和显示逻辑,提升一致性和可读性 - 修正session模块中fs.readdirSync调用的变量初始化问题 --- src/session.ts | 2 +- src/ui/DropdownMenu.tsx | 191 ++++++++++++++++++++++++++++++++++++++++ src/ui/PromptInput.tsx | 93 +++++++++---------- 3 files changed, 234 insertions(+), 52 deletions(-) create mode 100644 src/ui/DropdownMenu.tsx diff --git a/src/session.ts b/src/session.ts index 58a2c72..3382280 100644 --- a/src/session.ts +++ b/src/session.ts @@ -598,7 +598,7 @@ The candidate skills are as follows:\n\n`; if (!fs.existsSync(root)) { return []; } - let entries: fs.Dirent[] = []; + let entries: fs.Dirent[]; try { entries = fs.readdirSync(root, { withFileTypes: true }); } catch { diff --git a/src/ui/DropdownMenu.tsx b/src/ui/DropdownMenu.tsx new file mode 100644 index 0000000..3a963a5 --- /dev/null +++ b/src/ui/DropdownMenu.tsx @@ -0,0 +1,191 @@ +import React, { useMemo } from "react"; +import { Box, Text } from "ink"; + +/** + * Generic dropdown menu item structure + */ +export type DropdownMenuItem = { + /** Unique key for React list rendering */ + key: string; + /** Main label text (can include status indicators) */ + label: string; + /** Secondary description text (dimmed) */ + description?: string; + /** Whether this item is currently selected */ + selected?: boolean; + /** Whether to show a special status indicator (e.g., loaded checkmark) */ + statusIndicator?: { + symbol: string; + color: string; + }; +}; + +/** + * Props for the DropdownMenu component + */ +type DropdownMenuProps = { + /** List of items to display */ + items: DropdownMenuItem[]; + /** Index of the currently active/highlighted item */ + activeIndex: number; + /** Maximum number of visible items before scrolling */ + maxVisible?: number; + /** Container width in columns */ + width: number; + /** Optional title displayed at the top */ + title?: string; + /** Color for the title (default: "magenta") */ + titleColor?: string; + /** Color for the active item indicator (default: "cyanBright") */ + activeColor?: string; + /** Help text displayed at the bottom */ + helpText?: string; + /** Text to display when items list is empty */ + emptyText?: string; + /** Custom item renderer (overrides default rendering) */ + renderItem?: (item: DropdownMenuItem, isActive: boolean) => React.ReactNode; +}; + +/** + * Calculate the visible window start position for scrolling + * Ensures the activeIndex is always visible within the window + */ +export function calculateVisibleStart(activeIndex: number, totalItems: number, maxVisible: number): number { + return Math.min(Math.max(0, activeIndex - Math.floor((maxVisible - 1) / 2)), Math.max(0, totalItems - maxVisible)); +} + +/** + * Generic dropdown menu component with scrolling support + * Used by Skills Dropdown, Model Dropdown, and other selection menus + */ +const DropdownMenu = React.memo(function DropdownMenu({ + items, + activeIndex, + maxVisible = 8, + width, + title, + titleColor = "magenta", + activeColor = "cyanBright", + helpText, + emptyText = "No items found", + renderItem, +}: DropdownMenuProps): React.ReactElement | null { + // Calculate visible window + const visibleStart = calculateVisibleStart(activeIndex, items?.length, maxVisible); + const visibleItems = items?.slice(visibleStart, visibleStart + maxVisible); + + // 计算标签列最佳宽度:包含所有可能的前缀和后缀 + const labelColumnWidth = useMemo(() => { + if (visibleItems.length === 0) { + return 0; + } + // 计算每个 item 实际需要的最大宽度 + const maxContentWidth = Math.max( + ...visibleItems.map((item) => { + let width = 2; // prefix "› " or " " + if (item.selected !== undefined) { + width += 2; // "● " or "○ " + } + width += item.label.length; + if (item.statusIndicator) { + width += 2; // " ✓" or similar + } + return width; + }) + ); + const maxAllowed = Math.max(10, (width - 2) >> 1); // 容器50%宽度(减去gap),至少保留10列 + return Math.min(maxContentWidth, maxAllowed); + }, [visibleItems, width]); + + // Early return if no items + if (items?.length === 0) { + return ( + + {title ? ( + + {title} + + ) : null} + {emptyText} + {helpText ? {helpText} : null} + + ); + } + + return ( + + {/* Title */} + {title ? ( + + + {title} + + + ) : null} + + {/* Scroll indicator - top */} + {visibleStart > 0 ? ( + + … {visibleStart} above + + ) : null} + + {/* Visible items */} + {visibleItems.map((item, idx) => { + const actualIndex = visibleStart + idx; + const isActive = actualIndex === activeIndex; + + // Use custom renderer if provided + if (renderItem) { + return {renderItem(item, isActive)}; + } + + // Default rendering with selection indicator and optional features + return ( + + + + {isActive ? "› " : " "} + {item.selected !== undefined ? (item.selected ? "●" : "○") : null} {item.label} + {item.statusIndicator ? ( + {item.statusIndicator.symbol} + ) : null} + + + {item.description ? {`${item.description}`} : null} + + ); + })} + + {/* Scroll indicator - bottom */} + {visibleStart + visibleItems.length < items.length ? ( + + … {items.length - visibleStart - visibleItems.length} more + + ) : null} + + {/* Help text */} + {helpText ? ( + + {helpText} + + ) : null} + + ); +}); + +export default DropdownMenu; diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index b878961..a5f77f1 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, Text, useApp, useStdout } from "ink"; import chalk from "chalk"; import { @@ -35,6 +35,7 @@ import type { InputKey } from "./prompt"; import { useHiddenTerminalCursor, useTerminalExtendedKeys, useTerminalFocusReporting } from "./prompt"; import SlashCommandMenu from "./SlashCommandMenu"; import type { ModelConfigSelection, ReasoningEffort } from "../settings"; +import DropdownMenu from "./DropdownMenu"; export type PromptSubmission = { text: string; @@ -89,7 +90,7 @@ const PromptPrefixLine = React.memo(function PromptPrefixLine({ busy }: { busy: }, [busy]); const prefix = busy ? `${SPINNER_FRAMES[spinnerIndex]} ` : "> "; - return {prefix}; + return {prefix}; }); export const PromptInput = React.memo(function PromptInput({ @@ -671,8 +672,6 @@ export const PromptInput = React.memo(function PromptInput({ }); } - const visibleSkillStart = Math.min(Math.max(0, skillsDropdownIndex - 7), Math.max(0, skills.length - 8)); - const visibleSkills = skills.slice(visibleSkillStart, visibleSkillStart + 8); const modelDropdownItems = modelDropdownStep === "model" ? MODEL_COMMAND_MODELS.map((model) => ({ @@ -686,6 +685,11 @@ export const PromptInput = React.memo(function PromptInput({ description: option.thinkingEnabled ? `reasoningEffort: ${option.reasoningEffort}` : "thinking disabled", })); + const showFooterText = useMemo( + () => showMenu || showSkillsDropdown || modelDropdownStep !== null, + [showMenu, showSkillsDropdown, modelDropdownStep] + ); + return ( {imageUrls.length > 0 ? ( @@ -715,58 +719,45 @@ export const PromptInput = React.memo(function PromptInput({ {renderBufferWithCursor(buffer, !disabled && hasTerminalFocus, placeholder)} {showSkillsDropdown ? ( - - - Select Skills - - {skills.length === 0 ? ( - No skills found - ) : ( - visibleSkills.map((skill, idx) => { - const skillIndex = visibleSkillStart + idx; - const selected = isSkillSelected(selectedSkills, skill); - const active = skillIndex === skillsDropdownIndex; - return ( - - {active ? "› " : " "} - {selected ? "●" : "○"} {skill.name} - {skill.isLoaded ? : null} - {` ${skill.path}`} - - ); - }) - )} - {visibleSkillStart > 0 ? … {visibleSkillStart} above : null} - {visibleSkillStart + visibleSkills.length < skills.length ? ( - … {skills.length - visibleSkillStart - visibleSkills.length} more - ) : null} - space toggle · enter toggle · esc to close - + ({ + key: skill.path || skill.name, + label: skill.name, + description: skill.path, + selected: isSkillSelected(selectedSkills, skill), + statusIndicator: skill.isLoaded ? { symbol: "✓", color: "green" } : undefined, + }))} + activeIndex={skillsDropdownIndex} + activeColor="#229ac3" + maxVisible={8} + /> ) : null} {modelDropdownStep ? ( - - - {modelDropdownStep === "model" ? "Select Model" : "Select Thinking Mode"} - - {modelDropdownItems.map((item, idx) => { - const active = idx === modelDropdownIndex; - return ( - - {active ? "› " : " "} - {item.selected ? "●" : "○"} {item.label} - {item.description ? {` ${item.description}`} : null} - - ); - })} - - {modelDropdownStep === "model" + - + : "space/enter apply · esc to cancel" + } + items={modelDropdownItems.map((item) => ({ + key: item.label, + label: item.label, + description: item.description, + selected: item.selected, + }))} + activeIndex={modelDropdownIndex} + activeColor="#229ac3" + maxVisible={8} + /> ) : null} - {!showMenu && ( + {!showFooterText && ( {footerText} From 27000543800e7e3113c25ebac9df6d3acd60a7a4 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 09:48:56 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(session):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=8F=98=E6=9B=B4=E6=B6=88=E6=81=AF=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=92=8C=E7=9B=B8=E5=85=B3UI=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在模型配置变更时生成系统角色消息,包含模型设置详情 - 将模型变更消息添加至消息列表,支持消息追踪 - 在消息视图中新增模型变更消息渲染逻辑,展示模型名称和推理强度 - 调整用户消息视图布局,优化水平排列和间距 - 修改提示输入组件下拉菜单最大可见项数为6,提升视觉体验 - 扩展SessionMessage类型,支持模型变更相关元信息 - 新增单元测试覆盖DropdownMenu的可见起始项计算逻辑 --- src/session.ts | 6 ++ src/tests/dropdownMenu.test.ts | 148 +++++++++++++++++++++++++++++++++ src/ui/App.tsx | 51 +++++++++--- src/ui/MessageView.tsx | 45 +++++++--- src/ui/PromptInput.tsx | 4 +- 5 files changed, 230 insertions(+), 24 deletions(-) create mode 100644 src/tests/dropdownMenu.test.ts diff --git a/src/session.ts b/src/session.ts index 3382280..1fe1f6d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -121,6 +121,12 @@ export type MessageMeta = { resultMd?: string; asThinking?: boolean; isSummary?: boolean; + isModelChange?: boolean; + modelConfig?: { + model: string; + thinkingEnabled: boolean; + reasoningEffort?: string; + }; skill?: SkillInfo; }; diff --git a/src/tests/dropdownMenu.test.ts b/src/tests/dropdownMenu.test.ts new file mode 100644 index 0000000..3e4e3ef --- /dev/null +++ b/src/tests/dropdownMenu.test.ts @@ -0,0 +1,148 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { calculateVisibleStart } from "../ui/DropdownMenu"; + +test("calculateVisibleStart centers active item when possible", () => { + // 10 items, max 5 visible, active index 4 (middle) + // Should show items 2-6 (start at 2) + const start = calculateVisibleStart(4, 10, 5); + assert.equal(start, 2); +}); + +test("calculateVisibleStart handles active item at the beginning", () => { + // 10 items, max 5 visible, active index 0 + // Should show items 0-4 (start at 0) + const start = calculateVisibleStart(0, 10, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles active item at the end", () => { + // 10 items, max 5 visible, active index 9 (last) + // Should show items 5-9 (start at 5) + const start = calculateVisibleStart(9, 10, 5); + assert.equal(start, 5); +}); + +test("calculateVisibleStart handles fewer items than maxVisible", () => { + // 3 items, max 5 visible, active index 1 + // Should show all items (start at 0) + const start = calculateVisibleStart(1, 3, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles single item", () => { + // 1 item, max 5 visible, active index 0 + // Should start at 0 + const start = calculateVisibleStart(0, 1, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles empty list", () => { + // 0 items, max 5 visible, active index 0 + // Should start at 0 + const start = calculateVisibleStart(0, 0, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex near start with odd maxVisible", () => { + // 10 items, max 7 visible (odd), active index 2 + // floor((7-1)/2) = 3, so 2-3 = -1, clamped to 0 + const start = calculateVisibleStart(2, 10, 7); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex near start with even maxVisible", () => { + // 10 items, max 6 visible (even), active index 2 + // floor((6-1)/2) = 2, so 2-2 = 0 + const start = calculateVisibleStart(2, 10, 6); + assert.equal(start, 0); +}); + +test("calculateVisibleStart keeps active item centered in middle range", () => { + // 20 items, max 5 visible, active index 10 + // floor((5-1)/2) = 2, so 10-2 = 8 + const start = calculateVisibleStart(10, 20, 5); + assert.equal(start, 8); +}); + +test("calculateVisibleStart handles activeIndex at exact boundary", () => { + // 10 items, max 5 visible, active index 2 (boundary where centering starts) + // floor((5-1)/2) = 2, so 2-2 = 0 + const start = calculateVisibleStart(2, 10, 5); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex just after boundary", () => { + // 10 items, max 5 visible, active index 3 + // floor((5-1)/2) = 2, so 3-2 = 1 + const start = calculateVisibleStart(3, 10, 5); + assert.equal(start, 1); +}); + +test("calculateVisibleStart handles large maxVisible", () => { + // 10 items, max 100 visible, active index 5 + // Should show all items (start at 0) + const start = calculateVisibleStart(5, 10, 100); + assert.equal(start, 0); +}); + +test("calculateVisibleStart handles activeIndex equal to totalItems", () => { + // 10 items, max 5 visible, active index 10 (out of bounds) + // floor((5-1)/2) = 2, so 10-2 = 8, clamped to 5 (10-5) + const start = calculateVisibleStart(10, 10, 5); + assert.equal(start, 5); +}); + +test("calculateVisibleStart with maxVisible of 1", () => { + // 5 items, max 1 visible, active index 2 + // floor((1-1)/2) = 0, so 2-0 = 2, clamped to 4 (5-1) + const start = calculateVisibleStart(2, 5, 1); + assert.equal(start, 2); +}); + +test("calculateVisibleStart with maxVisible of 1 at end", () => { + // 5 items, max 1 visible, active index 4 (last) + // floor((1-1)/2) = 0, so 4-0 = 4, clamped to 4 (5-1) + const start = calculateVisibleStart(4, 5, 1); + assert.equal(start, 4); +}); + +test("calculateVisibleStart scrolling behavior - moving down", () => { + // Simulate scrolling through a list + // 10 items, max 5 visible + + // Start at index 0 + assert.equal(calculateVisibleStart(0, 10, 5), 0); + + // Move to index 2 (still centered) + assert.equal(calculateVisibleStart(2, 10, 5), 0); + + // Move to index 5 (window should scroll) + assert.equal(calculateVisibleStart(5, 10, 5), 3); + + // Move to index 8 (near end) + assert.equal(calculateVisibleStart(8, 10, 5), 5); + + // Move to index 9 (at end) + assert.equal(calculateVisibleStart(9, 10, 5), 5); +}); + +test("calculateVisibleStart scrolling behavior - moving up", () => { + // Simulate scrolling up through a list + // 10 items, max 5 visible + + // Start at index 9 (end) + assert.equal(calculateVisibleStart(9, 10, 5), 5); + + // Move to index 6 + assert.equal(calculateVisibleStart(6, 10, 5), 4); + + // Move to index 4 (window should scroll up) + assert.equal(calculateVisibleStart(4, 10, 5), 2); + + // Move to index 1 (near start) + assert.equal(calculateVisibleStart(1, 10, 5), 0); + + // Move to index 0 (at start) + assert.equal(calculateVisibleStart(0, 10, 5), 0); +}); diff --git a/src/ui/App.tsx b/src/ui/App.tsx index dd13d0f..d1719d2 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -261,16 +261,47 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R sessionManager.interruptActiveSession(); }, [sessionManager]); - const handleModelConfigChange = useCallback((selection: ModelConfigSelection): string => { - const current = resolveCurrentSettings(); - const { changed } = writeModelConfigSelection(selection, current); - const next = resolveCurrentSettings(); - setResolvedSettings(next); - if (!changed) { - return "Model settings unchanged"; - } - return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; - }, []); + const handleModelConfigChange = useCallback( + (selection: ModelConfigSelection): string => { + const current = resolveCurrentSettings(); + const { changed } = writeModelConfigSelection(selection, current); + const next = resolveCurrentSettings(); + setResolvedSettings(next); + + if (!changed) { + return "Model settings unchanged"; + } + + // 构建模型变更消息 + const activeSessionId = sessionManager.getActiveSessionId(); + const message: SessionMessage = { + id: crypto.randomUUID(), + sessionId: activeSessionId ?? "local", + role: "system", + content: `/model\n⎿ Set model to ${selection.model}`, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: new Date().toISOString(), + updateTime: new Date().toISOString(), + meta: { + isModelChange: true, + modelConfig: { + model: selection.model, + thinkingEnabled: selection.thinkingEnabled, + reasoningEffort: selection.reasoningEffort, + }, + }, + }; + + // 添加到消息列表 + setMessages((prev) => [...prev, message]); + + return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; + }, + [sessionManager] + ); const handleSubmit = useCallback( (submission: PromptSubmission) => { diff --git a/src/ui/MessageView.tsx b/src/ui/MessageView.tsx index ae9dd19..5f0c3df 100644 --- a/src/ui/MessageView.tsx +++ b/src/ui/MessageView.tsx @@ -16,17 +16,15 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | if (message.role === "user") { const text = message.content || "(no content)"; return ( - - - - {`>`} - - - {text} - {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( - {` 📎 ${message.contentParams.length} image attachment(s)`} - ) : null} - + + + {`>`} + + + {text} + {Array.isArray(message.contentParams) && message.contentParams.length > 0 ? ( + {` 📎 ${message.contentParams.length} image attachment(s)`} + ) : null} ); @@ -48,7 +46,9 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | return ( - {content ? {renderMarkdown(content)} : null} + + {content ? {renderMarkdown(content)} : null} + ); } @@ -81,6 +81,27 @@ export function MessageView({ message, collapsed }: Props): React.ReactElement | } if (message.role === "system") { + // 渲染模型变更消息 + if (message.meta?.isModelChange) { + return ( + + + {`>`} + + + /model + + ⎿ Set model to{" "} + + {message.meta.modelConfig?.model} + + {` (${message.meta.modelConfig?.reasoningEffort || "normal"} effort)`} + + + + ); + } + if (message.meta?.skill) { return ( diff --git a/src/ui/PromptInput.tsx b/src/ui/PromptInput.tsx index a5f77f1..6a11431 100644 --- a/src/ui/PromptInput.tsx +++ b/src/ui/PromptInput.tsx @@ -733,7 +733,7 @@ export const PromptInput = React.memo(function PromptInput({ }))} activeIndex={skillsDropdownIndex} activeColor="#229ac3" - maxVisible={8} + maxVisible={6} /> ) : null} {modelDropdownStep ? ( @@ -753,7 +753,7 @@ export const PromptInput = React.memo(function PromptInput({ }))} activeIndex={modelDropdownIndex} activeColor="#229ac3" - maxVisible={8} + maxVisible={6} /> ) : null} From ef875a056d627d1cb9f08ea83374087e386d0f71 Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 10:19:33 +0800 Subject: [PATCH 3/4] =?UTF-8?q?feat(session):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=E7=B3=BB=E7=BB=9F=E6=B6=88=E6=81=AF=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=8F=8A=E6=A8=A1=E5=9E=8B=E5=8F=98=E6=9B=B4=E6=B6=88?= =?UTF-8?q?=E6=81=AF=E5=A4=84=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 addSessionSystemMessage 方法,用于统一添加系统消息 - 优化 App.tsx 中模型切换逻辑,调用 sessionManager 添加系统消息 - 保持无活动会话时依旧能将消息追加到本地消息列表 - 重构消息创建流程,抽取 meta 和 content 变量提高代码清晰度 - 确保系统消息包含准确的时间戳及元数据配置 --- src/session.ts | 19 ++++++++++++++++++ src/ui/App.tsx | 52 +++++++++++++++++++++++++++++--------------------- 2 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/session.ts b/src/session.ts index 1fe1f6d..436da8c 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1332,6 +1332,25 @@ ${skillMd} return messages; } + addSessionSystemMessage(sessionId: string, content: string, meta?: MessageMeta): void { + const now = new Date().toISOString(); + const message: SessionMessage = { + id: crypto.randomUUID(), + sessionId, + role: "system", + content, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + meta, + }; + this.appendSessionMessage(sessionId, message); + this.onAssistantMessage(message, false); + } + private normalizeSessionMessage(message: SessionMessage): SessionMessage { if (message.role !== "tool") { return message; diff --git a/src/ui/App.tsx b/src/ui/App.tsx index d1719d2..ff9ed5b 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -8,6 +8,7 @@ import OpenAI from "openai"; import { SessionManager, type LlmStreamProgress, + type MessageMeta, type SessionEntry, type SessionMessage, type SessionStatus, @@ -272,31 +273,38 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return "Model settings unchanged"; } - // 构建模型变更消息 const activeSessionId = sessionManager.getActiveSessionId(); - const message: SessionMessage = { - id: crypto.randomUUID(), - sessionId: activeSessionId ?? "local", - role: "system", - content: `/model\n⎿ Set model to ${selection.model}`, - contentParams: null, - messageParams: null, - compacted: false, - visible: true, - createTime: new Date().toISOString(), - updateTime: new Date().toISOString(), - meta: { - isModelChange: true, - modelConfig: { - model: selection.model, - thinkingEnabled: selection.thinkingEnabled, - reasoningEffort: selection.reasoningEffort, - }, + const meta: MessageMeta = { + isModelChange: true, + modelConfig: { + model: selection.model, + thinkingEnabled: selection.thinkingEnabled, + reasoningEffort: selection.reasoningEffort, }, }; - - // 添加到消息列表 - setMessages((prev) => [...prev, message]); + const content = `/model\n⎿ Set model to ${selection.model}`; + + if (activeSessionId) { + sessionManager.addSessionSystemMessage(activeSessionId, content, meta); + } else { + const now = new Date().toISOString(); + setMessages((prev) => [ + ...prev, + { + id: crypto.randomUUID(), + sessionId: "local", + role: "system" as const, + content, + contentParams: null, + messageParams: null, + compacted: false, + visible: true, + createTime: now, + updateTime: now, + meta, + }, + ]); + } return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; }, From d3aab4de5579ea9660af2a7f0371504ee625f01f Mon Sep 17 00:00:00 2001 From: hcyang Date: Thu, 14 May 2026 10:28:58 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(merge):=20=E5=90=88=E5=B9=B6=20main=20?= =?UTF-8?q?=E5=88=86=E6=94=AF=E4=BB=A3=E7=A0=81=E5=B9=B6=E5=A4=84=E7=90=86?= =?UTF-8?q?=E5=86=B2=E7=AA=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/ui/App.tsx | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/src/ui/App.tsx b/src/ui/App.tsx index dbc5935..8728219 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -269,19 +269,6 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R const { changed } = writeModelConfigSelection(selection, current, projectRoot); const next = resolveCurrentSettings(projectRoot); setResolvedSettings(next); - if (!changed) { - return "Model settings unchanged"; - } - return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; - }, - [projectRoot] - ); - const handleModelConfigChange = useCallback( - (selection: ModelConfigSelection): string => { - const current = resolveCurrentSettings(); - const { changed } = writeModelConfigSelection(selection, current); - const next = resolveCurrentSettings(); - setResolvedSettings(next); if (!changed) { return "Model settings unchanged"; @@ -322,7 +309,7 @@ export function App({ projectRoot, version = "", onRestart }: AppProps): React.R return `Model settings updated: ${formatModelConfig(current)} → ${formatModelConfig(next)}`; }, - [sessionManager] + [projectRoot, sessionManager] ); const handleSubmit = useCallback(