From 10725df8abfa847b3f2aaab04a1424217ef26aad Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 27 Mar 2026 22:19:07 +0800 Subject: [PATCH] =?UTF-8?q?=E7=8E=B0=E5=9C=A8=E4=BC=9A=E8=AF=9D=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E6=A1=86=E4=BC=9A=E8=AE=B0=E4=BD=8F=E7=94=A8=E6=88=B7?= =?UTF-8?q?=E7=9A=84=E9=85=8D=E7=BD=AE=E9=80=89=E9=A1=B9=E5=92=8C=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=80=89=E6=8B=A9=EF=BC=8C=E9=81=BF=E5=85=8D=E6=AF=8F?= =?UTF-8?q?=E6=AC=A1=E9=83=BD=E8=A6=81=E9=87=8D=E6=96=B0=E5=8B=BE=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../conversation-detail-panel.tsx | 24 +- src/contexts/acp-connections-context.tsx | 45 +++- src/lib/selector-prefs-storage.ts | 221 ++++++++++++++++++ 3 files changed, 283 insertions(+), 7 deletions(-) create mode 100644 src/lib/selector-prefs-storage.ts diff --git a/src/components/conversations/conversation-detail-panel.tsx b/src/components/conversations/conversation-detail-panel.tsx index 3de62ec..c482247 100644 --- a/src/components/conversations/conversation-detail-panel.tsx +++ b/src/components/conversations/conversation-detail-panel.tsx @@ -47,6 +47,10 @@ import { type MessageTurn, type PromptDraft, } from "@/lib/types" +import { + getSavedModeId, + saveModePreference, +} from "@/lib/selector-prefs-storage" import { buildConversationDraftStorageKey, buildNewConversationDraftStorageKey, @@ -724,7 +728,7 @@ const ConversationTabView = memo(function ConversationTabView({ if (dbConvIdRef.current) return setDraftAgentType(nextAgentType) - setModeId(null) + setModeId(getSavedModeId(nextAgentType)) setAgentConnectError(null) const s = connStatusRef.current @@ -759,6 +763,20 @@ const ConversationTabView = memo(function ConversationTabView({ [connConnect, connDisconnect, workingDirForConnection] ) + const handleModeChange = useCallback( + (newModeId: string) => { + setModeId(newModeId) + // Persist mode selection to localStorage immediately + if (conn.modes) { + saveModePreference(selectedAgent, { + ...conn.modes, + current_mode_id: newModeId, + }) + } + }, + [conn.modes, selectedAgent] + ) + const handleAnswerQuestion = useCallback( (answer: string) => { if (connStatus !== "connected") return @@ -853,7 +871,7 @@ const ConversationTabView = memo(function ConversationTabView({ configOptionsLoading={configOptionsLoading} selectorsLoading={selectorsLoading} selectedModeId={selectedModeId} - onModeChange={setModeId} + onModeChange={handleModeChange} onConfigOptionChange={handleSetConfigOption} availableCommands={connectionCommands} attachmentTabId={tabId} @@ -924,7 +942,7 @@ const ConversationTabView = memo(function ConversationTabView({ configOptionsLoading={configOptionsLoading} selectorsLoading={selectorsLoading} selectedModeId={selectedModeId} - onModeChange={setModeId} + onModeChange={handleModeChange} onConfigOptionChange={handleSetConfigOption} availableCommands={connectionCommands} attachmentTabId={tabId} diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 8c17d4d..9311fa7 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -43,6 +43,13 @@ import { IDLE_SWEEP_INTERVAL_MS, } from "@/lib/constants" import { notifyTurnComplete } from "@/lib/notification" +import { + applySavedModePreference, + applySavedConfigPreferences, + saveModePreference, + saveConfigPreference, + clearStalePrefs, +} from "@/lib/selector-prefs-storage" import { useAlertContext, type AlertAction } from "@/contexts/alert-context" import { useFolderContext } from "@/contexts/folder-context" @@ -1523,17 +1530,20 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { case "session_modes": { flushStreamingQueue() const modeConn = storeRef.current.connections.get(contextKey) + const resolvedModes = modeConn + ? applySavedModePreference(modeConn.agentType, e.modes) + : e.modes dispatch({ type: "SESSION_MODES", contextKey, - modes: e.modes, + modes: resolvedModes, }) if (modeConn) { const entry = selectorsCache.get(modeConn.agentType) ?? { modes: null, configOptions: null, } - entry.modes = e.modes + entry.modes = resolvedModes selectorsCache.set(modeConn.agentType, entry) } break @@ -1541,17 +1551,20 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { case "session_config_options": { flushStreamingQueue() const cfgConn = storeRef.current.connections.get(contextKey) + const resolvedConfigOptions = cfgConn + ? applySavedConfigPreferences(cfgConn.agentType, e.config_options) + : e.config_options dispatch({ type: "SESSION_CONFIG_OPTIONS", contextKey, - configOptions: e.config_options, + configOptions: resolvedConfigOptions, }) if (cfgConn) { const entry = selectorsCache.get(cfgConn.agentType) ?? { modes: null, configOptions: null, } - entry.configOptions = e.config_options + entry.configOptions = resolvedConfigOptions selectorsCache.set(cfgConn.agentType, entry) } break @@ -1571,6 +1584,13 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { configOptions: rdyConn.configOptions, }) } + // Clean up stale localStorage prefs for agents that genuinely + // no longer provide modes or config options. + if (rdyConn) { + const hasModes = (rdyConn.modes?.available_modes.length ?? 0) > 0 + const hasConfig = (rdyConn.configOptions?.length ?? 0) > 0 + clearStalePrefs(rdyConn.agentType, hasModes, hasConfig) + } break } case "prompt_capabilities": @@ -1956,6 +1976,15 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { const setMode = useCallback(async (contextKey: string, modeId: string) => { const conn = storeRef.current.connections.get(contextKey) if (!conn) return + // Persist user's mode selection to localStorage + const modes = + conn.modes ?? selectorsCache.get(conn.agentType)?.modes ?? null + if (modes) { + saveModePreference(conn.agentType, { + ...modes, + current_mode_id: modeId, + }) + } lastActivityRef.current.set(contextKey, Date.now()) await acpSetMode(conn.connectionId, modeId) }, []) @@ -1970,6 +1999,14 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { configId, valueId, }) + // Persist user selection to localStorage + const updatedConn = storeRef.current.connections.get(contextKey) + const allOptions = + updatedConn?.configOptions ?? + selectorsCache.get(conn.agentType)?.configOptions + if (allOptions) { + saveConfigPreference(conn.agentType, configId, valueId, allOptions) + } lastActivityRef.current.set(contextKey, Date.now()) await acpSetConfigOption(conn.connectionId, configId, valueId) }, diff --git a/src/lib/selector-prefs-storage.ts b/src/lib/selector-prefs-storage.ts new file mode 100644 index 0000000..80b10df --- /dev/null +++ b/src/lib/selector-prefs-storage.ts @@ -0,0 +1,221 @@ +"use client" + +/** + * Persists user's selector preferences (mode & config option selections) + * per agentType to localStorage, so they survive session restarts. + * + * Structure hash is stored alongside values — when the available options + * change (new/removed/renamed items) the saved prefs are discarded. + * + * Agents may emit empty selectors during early init (config_options=None + * becomes []), followed by real selectors later. We therefore only apply + * or invalidate prefs when incoming data is non-empty. Stale prefs for + * agents that genuinely lose all selectors are cleaned up at + * `selectors_ready` via `clearStalePrefs()`. + */ + +import type { SessionConfigOptionInfo, SessionModeStateInfo } from "@/lib/types" + +const STORAGE_KEY = "codeg:selector-prefs" + +interface SelectorPrefs { + modeId?: string + modesHash?: string + configValues?: Record + configHash?: string +} + +type AllPrefs = Record + +function readAll(): AllPrefs { + if (typeof window === "undefined") return {} + try { + const raw = localStorage.getItem(STORAGE_KEY) + return raw ? (JSON.parse(raw) as AllPrefs) : {} + } catch { + return {} + } +} + +function writeAll(all: AllPrefs) { + if (typeof window === "undefined") return + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(all)) + } catch { + /* ignore */ + } +} + +function updatePrefs( + agentType: string, + fn: (prefs: SelectorPrefs) => SelectorPrefs +) { + const all = readAll() + all[agentType] = fn(all[agentType] ?? {}) + writeAll(all) +} + +// ── Hash helpers ── + +function hashModes(modes: SessionModeStateInfo): string { + return modes.available_modes.map((m) => m.id).join("\0") +} + +function hashConfigOptions(options: SessionConfigOptionInfo[]): string { + return options + .map((o) => { + if (o.kind.type !== "select") return o.id + const vals = o.kind.options.map((v) => v.value).join(",") + return `${o.id}:${vals}` + }) + .join("\0") +} + +// ── Read ── + +/** Read saved mode id for an agent (no validation, just the raw value). */ +export function getSavedModeId(agentType: string): string | null { + const all = readAll() + return all[agentType]?.modeId ?? null +} + +// ── Save (user actions only) ── + +export function saveModePreference( + agentType: string, + modes: SessionModeStateInfo +) { + updatePrefs(agentType, (prefs) => ({ + ...prefs, + modeId: modes.current_mode_id, + modesHash: hashModes(modes), + })) +} + +export function saveConfigPreference( + agentType: string, + configId: string, + valueId: string, + allOptions: SessionConfigOptionInfo[] +) { + updatePrefs(agentType, (prefs) => ({ + ...prefs, + configValues: { ...prefs.configValues, [configId]: valueId }, + configHash: hashConfigOptions(allOptions), + })) +} + +// ── Apply (on incoming server events) ── + +/** + * Apply saved mode preference to incoming server modes. + * Skips empty mode lists (agent still initializing). + * Clears prefs when structure genuinely changes. + */ +export function applySavedModePreference( + agentType: string, + modes: SessionModeStateInfo +): SessionModeStateInfo { + const all = readAll() + const prefs = all[agentType] + if (!prefs?.modeId || !prefs.modesHash) return modes + if (modes.available_modes.length === 0) return modes + + const incomingHash = hashModes(modes) + if (prefs.modesHash !== incomingHash) { + delete prefs.modeId + delete prefs.modesHash + all[agentType] = prefs + writeAll(all) + return modes + } + + if (!modes.available_modes.some((m) => m.id === prefs.modeId)) { + return modes + } + + if (modes.current_mode_id === prefs.modeId) return modes + + return { ...modes, current_mode_id: prefs.modeId! } +} + +/** + * Apply saved config option preferences to incoming server config options. + * Skips empty option lists (agent still initializing). + * Clears prefs when structure genuinely changes. + */ +export function applySavedConfigPreferences( + agentType: string, + options: SessionConfigOptionInfo[] +): SessionConfigOptionInfo[] { + const all = readAll() + const prefs = all[agentType] + if (!prefs?.configValues || !prefs.configHash) return options + if (options.length === 0) return options + + const incomingHash = hashConfigOptions(options) + if (prefs.configHash !== incomingHash) { + delete prefs.configValues + delete prefs.configHash + all[agentType] = prefs + writeAll(all) + return options + } + + let changed = false + const merged = options.map((opt) => { + if (opt.kind.type !== "select") return opt + const savedValue = prefs.configValues![opt.id] + if (!savedValue || savedValue === opt.kind.current_value) return opt + if (!opt.kind.options.some((o) => o.value === savedValue)) return opt + changed = true + return { + ...opt, + kind: { ...opt.kind, current_value: savedValue }, + } + }) + + return changed ? merged : options +} + +// ── Cleanup (called at selectors_ready) ── + +/** + * Called when `selectors_ready` fires — initialization is complete. + * If the agent ended up with no modes / no config options, clear + * any stale saved prefs for that category so they don't linger forever. + */ +export function clearStalePrefs( + agentType: string, + hasModes: boolean, + hasConfigOptions: boolean +) { + const all = readAll() + const prefs = all[agentType] + if (!prefs) return + + let dirty = false + if (!hasModes && (prefs.modeId || prefs.modesHash)) { + delete prefs.modeId + delete prefs.modesHash + dirty = true + } + if (!hasConfigOptions && (prefs.configValues || prefs.configHash)) { + delete prefs.configValues + delete prefs.configHash + dirty = true + } + if (!dirty) return + + const isEmpty = + !prefs.modeId && + !prefs.modesHash && + !prefs.configValues && + !prefs.configHash + if (isEmpty) { + delete all[agentType] + } else { + all[agentType] = prefs + } + writeAll(all) +}