From dd85ba4ef3826f55b97680f3c675cc1b38991836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=AC=E9=A3=8E=E5=90=B9=E9=9B=A8?= <2686367411@qq.com> Date: Tue, 27 Oct 2026 09:47:22 +0800 Subject: [PATCH] feat: add configurable chat process collapse modes --- .../message/chat-process-collapse.test.tsx | 168 ++++++++++++++++++ .../message/completed-turn-content.tsx | 58 +++++- .../settings/chat-process-settings.test.tsx | 25 +++ .../settings/chat-process-settings.tsx | 52 ++++++ src/components/settings/general-settings.tsx | 3 + src/i18n/messages/ar.json | 7 + src/i18n/messages/de.json | 7 + src/i18n/messages/en.json | 7 + src/i18n/messages/es.json | 7 + src/i18n/messages/fr.json | 7 + src/i18n/messages/ja.json | 7 + src/i18n/messages/ko.json | 7 + src/i18n/messages/pt.json | 7 + src/i18n/messages/zh-CN.json | 7 + src/i18n/messages/zh-TW.json | 7 + src/lib/chat-process-collapse.test.tsx | 58 ++++++ src/lib/chat-process-collapse.ts | 55 ++++++ 17 files changed, 486 insertions(+), 3 deletions(-) create mode 100644 src/components/message/chat-process-collapse.test.tsx create mode 100644 src/components/settings/chat-process-settings.test.tsx create mode 100644 src/components/settings/chat-process-settings.tsx create mode 100644 src/lib/chat-process-collapse.test.tsx create mode 100644 src/lib/chat-process-collapse.ts diff --git a/src/components/message/chat-process-collapse.test.tsx b/src/components/message/chat-process-collapse.test.tsx new file mode 100644 index 00000000000..7f4b5fc99b6 --- /dev/null +++ b/src/components/message/chat-process-collapse.test.tsx @@ -0,0 +1,168 @@ +import { useState } from "react" +import { act, fireEvent, render, screen } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, describe, expect, it } from "vitest" + +import enMessages from "@/i18n/messages/en.json" +import { EMPTY_TOOL_APPROVALS } from "@/lib/activity/approval" +import type { AdaptedContentPart } from "@/lib/adapters/ai-elements-adapter" +import { ActivityLiveProvider } from "./activity/activity-live-context" +import { CompletedTurnContent } from "./completed-turn-content" + +const KEY = "settings:chat-process-collapse:v1" +function setMode(mode: string) { + localStorage.setItem(KEY, mode) + window.dispatchEvent(new Event("codeg:chat-process-collapse-changed")) +} + +function Turn({ + completed, + failed = false, + waiting = false, +}: { + completed: boolean + failed?: boolean + waiting?: boolean +}) { + const [open, setOpen] = useState(true) + const [parts] = useState([ + { type: "text", text: "Inspecting source files" }, + { + type: "tool-call", + toolCallId: "collapse-test", + toolName: "Read", + input: '{"file_path":"src/app.tsx"}', + state: failed ? "output-error" : "output-available", + output: "source", + }, + { type: "text", text: "Final answer stays visible" }, + ]) + return ( + + + + + + ) +} + +const toggle = () => + screen.getByRole("button", { name: /^(Working|Activity)$/ }) + +afterEach(() => { + localStorage.removeItem(KEY) + window.dispatchEvent(new Event("codeg:chat-process-collapse-changed")) +}) + +describe("聊天过程折叠策略", () => { + it("默认保留当前轮次的展开行为", () => { + render() + expect(toggle()).toHaveAttribute("aria-expanded", "true") + }) + + it("执行结束后折叠,并保留最终回答", () => { + setMode("after-completion") + const view = render() + expect(toggle()).toHaveAttribute("aria-expanded", "true") + view.rerender() + expect(toggle()).toHaveAttribute("aria-expanded", "false") + expect(screen.getByText("Final answer stays visible")).toBeVisible() + fireEvent.click(toggle()) + expect(toggle()).toHaveAttribute("aria-expanded", "true") + view.rerender() + expect(toggle()).toHaveAttribute("aria-expanded", "true") + }) + + it("始终折叠模式在执行中也默认收起,仍可手动展开", () => { + setMode("always") + render() + expect(toggle()).toHaveAttribute("aria-expanded", "false") + fireEvent.click(toggle()) + expect(toggle()).toHaveAttribute("aria-expanded", "true") + }) + + it("已有会话立即响应设置切换", () => { + render() + act(() => setMode("always")) + expect(toggle()).toHaveAttribute("aria-expanded", "false") + act(() => setMode("current")) + expect(toggle()).toHaveAttribute("aria-expanded", "true") + }) + + it("响应其他窗口的设置变更", () => { + render() + act(() => { + localStorage.setItem(KEY, "always") + window.dispatchEvent(new StorageEvent("storage", { key: KEY })) + }) + expect(toggle()).toHaveAttribute("aria-expanded", "false") + }) + + it("始终折叠时仍显示错误", () => { + setMode("always") + render() + expect(screen.getByText("Inspecting source files")).toBeVisible() + }) + + it("始终折叠时仍显示待用户确认的过程", () => { + setMode("always") + render() + expect(screen.getByText("Inspecting source files")).toBeVisible() + }) + + it("已有历史会话可展开,虚拟列表重新挂载后保留选择", () => { + setMode("always") + const parts: AdaptedContentPart[] = [ + { type: "reasoning", content: "Checking history", isStreaming: false }, + { type: "text", text: "Historical answer" }, + ] + const ui = ( + + + + ) + const view = render(ui) + expect(toggle()).toHaveAttribute("aria-expanded", "false") + fireEvent.click(toggle()) + view.unmount() + render(ui) + expect(toggle()).toHaveAttribute("aria-expanded", "true") + expect(screen.getByText("Historical answer")).toBeVisible() + }) + + it("不会折叠唯一的工具结果或丢失最终输出", () => { + setMode("always") + render( + + + + ) + expect(screen.getByText("Important tool result")).toBeVisible() + expect(screen.queryByRole("button", { name: "Activity" })).toBeNull() + }) +}) diff --git a/src/components/message/completed-turn-content.tsx b/src/components/message/completed-turn-content.tsx index 3ebe71bb367..bf7c3c56c3b 100644 --- a/src/components/message/completed-turn-content.tsx +++ b/src/components/message/completed-turn-content.tsx @@ -9,6 +9,10 @@ import { } from "@/lib/adapters/ai-elements-adapter" import { formatElapsedLabel } from "@/lib/format-elapsed" import { cn } from "@/lib/utils" +import { + useChatProcessCollapse, + type ChatProcessCollapseMode, +} from "@/lib/chat-process-collapse" import { Collapsible, CollapsibleContent, @@ -74,6 +78,16 @@ const manualFold = new WeakMap< { epoch: number; open: boolean } >() +interface ProcessFoldChoice { + mode: ChatProcessCollapseMode + epoch: number + settled: boolean + open?: boolean +} + +// 与现有手动折叠相同,保留虚拟列表卸载前的选择,并随 parts 回收。 +const processFold = new WeakMap() + export const CompletedTurnContent = memo(function CompletedTurnContent({ parts, durationMs, @@ -103,6 +117,7 @@ export const CompletedTurnContent = memo(function CompletedTurnContent({ pending?: boolean }) { const tElapsed = useTranslations("Folder.chat.liveTurnStats") + const collapseMode = useChatProcessCollapse() const split = useMemo(() => splitAssistantTurnParts(parts), [parts]) const [localOpen, setLocalOpen] = useState(() => { @@ -135,10 +150,44 @@ export const CompletedTurnContent = memo(function CompletedTurnContent({ } } - const open = currentRound ? roundOpen : localOpen + // 结束后折叠模式在结束时重置一次;之后用户仍可重新展开。 + const settled = collapseMode === "after-completion" && completed + const [processChoice, setProcessChoice] = useState( + () => + processFold.get(parts) ?? { + mode: collapseMode, + epoch: foldEpoch, + settled, + } + ) + const choiceMatches = + processChoice.mode === collapseMode && + processChoice.epoch === foldEpoch && + processChoice.settled === settled + if (!choiceMatches) { + setProcessChoice({ mode: collapseMode, epoch: foldEpoch, settled }) + } + const open = + collapseMode === "current" + ? currentRound + ? roundOpen + : localOpen + : ((choiceMatches ? processChoice.open : undefined) ?? + (collapseMode === "after-completion" && !completed)) const handleOpenChange = useCallback( (next: boolean) => { + if (collapseMode !== "current") { + const choice = { + mode: collapseMode, + epoch: foldEpoch, + settled, + open: next, + } + processFold.set(parts, choice) + setProcessChoice(choice) + return + } if (currentRound) { onRoundOpenChange?.(next) return @@ -146,7 +195,7 @@ export const CompletedTurnContent = memo(function CompletedTurnContent({ manualFold.set(parts, { epoch: foldEpoch, open: next }) setLocalOpen(next) }, - [currentRound, foldEpoch, onRoundOpenChange, parts] + [collapseMode, settled, currentRound, foldEpoch, onRoundOpenChange, parts] ) const elapsed = @@ -165,7 +214,7 @@ export const CompletedTurnContent = memo(function CompletedTurnContent({ // has not been written yet, so applying it would withhold the toggle for the // whole stream and hand it over one beat before the turn ends. Folding a live // reply is then an explicit choice; the round settling re-applies the rule. - const foldable = + const canFold = split.progress.length > 0 && (!completed || hasVisibleAnswer(split.answer)) // What is drawn: a reply that cannot fold shows its steps whatever `open` // says. @@ -248,6 +297,9 @@ export const CompletedTurnContent = memo(function CompletedTurnContent({ // reply with its steps folded ends on the turning mark under whatever does // end it instead. const { awaitingUser } = useActivityLive() + // 自动折叠不能遮住失败或等待用户处理的交互。 + const foldable = + canFold && (collapseMode === "current" || (!failureKey && !awaitingUser)) const working = isStreaming && !awaitingUser const footShown = bodyOpen && (working || (foldable && activity.items.length > 0)) diff --git a/src/components/settings/chat-process-settings.test.tsx b/src/components/settings/chat-process-settings.test.tsx new file mode 100644 index 00000000000..e5d6adebe4a --- /dev/null +++ b/src/components/settings/chat-process-settings.test.tsx @@ -0,0 +1,25 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { NextIntlClientProvider } from "next-intl" +import { afterEach, expect, it } from "vitest" + +import zhMessages from "@/i18n/messages/zh-CN.json" +import { getChatProcessCollapse } from "@/lib/chat-process-collapse" +import { ChatProcessSettingsSection } from "./chat-process-settings" + +afterEach(() => localStorage.removeItem("settings:chat-process-collapse:v1")) + +it("在设置中提供三个模式并持久化用户选择", async () => { + render( + + + + ) + expect( + screen.getByRole("combobox", { name: "聊天过程详情" }) + ).toHaveTextContent("执行过程全程展开") + fireEvent.click(screen.getByRole("combobox")) + expect(screen.getAllByRole("option")).toHaveLength(3) + fireEvent.click(screen.getByRole("option", { name: "执行结束后折叠" })) + await waitFor(() => expect(getChatProcessCollapse()).toBe("after-completion")) + expect(screen.getByRole("combobox")).toHaveTextContent("执行结束后折叠") +}) diff --git a/src/components/settings/chat-process-settings.tsx b/src/components/settings/chat-process-settings.tsx new file mode 100644 index 00000000000..fe72d805ff4 --- /dev/null +++ b/src/components/settings/chat-process-settings.tsx @@ -0,0 +1,52 @@ +"use client" + +import { MessageSquareText } from "lucide-react" +import { useTranslations } from "next-intl" + +import { SettingsSection } from "@/components/shared/settings-section" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + CHAT_PROCESS_COLLAPSE_MODES, + setChatProcessCollapse, + useChatProcessCollapse, + type ChatProcessCollapseMode, +} from "@/lib/chat-process-collapse" + +export function ChatProcessSettingsSection() { + const t = useTranslations("ChatProcessSettings") + const mode = useChatProcessCollapse() + + return ( + + setChatProcessCollapse(value as ChatProcessCollapseMode) + } + > + + + + + {CHAT_PROCESS_COLLAPSE_MODES.map((value) => ( + + {t(value)} + + ))} + + + } + /> + ) +} diff --git a/src/components/settings/general-settings.tsx b/src/components/settings/general-settings.tsx index 49f9b989262..db0240e7945 100644 --- a/src/components/settings/general-settings.tsx +++ b/src/components/settings/general-settings.tsx @@ -47,6 +47,7 @@ import { DesktopNotificationSettingsSection } from "@/components/settings/deskto import { MentionPanelSettingsSection } from "@/components/settings/mention-panel-settings" import { NotificationSoundSettingsSection } from "@/components/settings/notification-sound-settings" import { WindowAttentionSettingsSection } from "@/components/settings/window-attention-settings" +import { ChatProcessSettingsSection } from "@/components/settings/chat-process-settings" const TERMINAL_SHELL_OPTION_SYSTEM = "system" const TERMINAL_SHELL_OPTION_CUSTOM = "custom" @@ -515,6 +516,8 @@ export function GeneralSettings() { )} + + diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index cbfc27b92f7..1cc358a3ddd 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -7692,5 +7692,12 @@ "probeFailed": "تعذّر على المتصفح المدمج التأكد من أنه يصل إلى المضيف البعيد عبر النفق. حاول مرة أخرى بعد قليل." } } + }, + "ChatProcessSettings": { + "title": "تفاصيل عملية الدردشة", + "description": "حدد وقت طي المهارات والأوامر واستدعاءات الأدوات. تظل الإجابات النهائية ظاهرة ويمكن توسيع التفاصيل يدوياً. تظل الأخطاء والتفاعلات المعلقة ظاهرة.", + "current": "الإبقاء على السلوك الحالي", + "after-completion": "الطي بعد الاكتمال", + "always": "الطي دائماً بشكل افتراضي" } } diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index e6c5b50fe62..92f703d0808 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -7692,5 +7692,12 @@ "probeFailed": "Der integrierte Browser konnte nicht bestätigen, dass er den entfernten Host über den Tunnel erreicht. Versuche es gleich noch einmal." } } + }, + "ChatProcessSettings": { + "title": "Chat-Prozessdetails", + "description": "Legt fest, wann Skills, Befehle und Werkzeugaufrufe eingeklappt werden. Abschließende Antworten bleiben sichtbar; Details lassen sich manuell öffnen. Fehler und ausstehende Interaktionen bleiben sichtbar.", + "current": "Aktuelles Verhalten beibehalten", + "after-completion": "Nach Abschluss einklappen", + "always": "Standardmäßig immer einklappen" } } diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 9d9dd7d1169..5b0896e0397 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -7692,5 +7692,12 @@ "probeFailed": "The built-in browser couldn't confirm that it reaches the remote host through the tunnel. Try again in a moment." } } + }, + "ChatProcessSettings": { + "title": "Chat process details", + "description": "Control when skills, commands and tool calls are collapsed. Final answers remain visible, and details can be expanded manually. Errors and pending interactions stay visible.", + "current": "Keep current behavior", + "after-completion": "Collapse after completion", + "always": "Always collapse by default" } } diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 16b26cf6b29..7b1cecdf683 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -7692,5 +7692,12 @@ "probeFailed": "El navegador integrado no pudo confirmar que llega al host remoto a través del túnel. Vuelve a intentarlo en un momento." } } + }, + "ChatProcessSettings": { + "title": "Detalles del proceso del chat", + "description": "Controla cuándo se contraen las habilidades, los comandos y las llamadas a herramientas. Las respuestas finales permanecen visibles y los detalles se pueden expandir manualmente. Los errores y las interacciones pendientes siguen visibles.", + "current": "Mantener el comportamiento actual", + "after-completion": "Contraer al finalizar", + "always": "Contraer siempre por defecto" } } diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 07c58bd4432..58e62f2159e 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -7692,5 +7692,12 @@ "probeFailed": "Le navigateur intégré n'a pas pu confirmer qu'il atteint l'hôte distant par le tunnel. Réessayez dans un instant." } } + }, + "ChatProcessSettings": { + "title": "Détails du processus de discussion", + "description": "Choisissez quand replier les compétences, commandes et appels aux outils. Les réponses finales restent visibles et les détails peuvent être dépliés manuellement. Les erreurs et les interactions en attente restent visibles.", + "current": "Conserver le comportement actuel", + "after-completion": "Replier après la fin", + "always": "Toujours replier par défaut" } } diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 6fc728084d7..7d65ac1031a 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -7692,5 +7692,12 @@ "probeFailed": "内蔵ブラウザーがトンネル経由でリモートホストに届くことを確認できませんでした。しばらくしてからもう一度お試しください。" } } + }, + "ChatProcessSettings": { + "title": "チャットの処理詳細", + "description": "スキル、コマンド、ツール呼び出しの折りたたみ方を設定します。最終回答は常に表示され、詳細は手動で展開できます。エラーと確認待ちの操作は表示されます。", + "current": "現在の動作を維持", + "after-completion": "完了後に折りたたむ", + "always": "常に折りたたんで表示" } } diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 720b2bdc646..f41ea01d616 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -7692,5 +7692,12 @@ "probeFailed": "내장 브라우저가 터널을 통해 원격 호스트에 도달하는지 확인할 수 없습니다. 잠시 후 다시 시도하세요." } } + }, + "ChatProcessSettings": { + "title": "채팅 처리 세부 정보", + "description": "스킬, 명령 및 도구 호출의 접기 방식을 설정합니다. 최종 답변은 항상 표시되며 세부 정보는 직접 펼칠 수 있습니다. 오류와 확인 대기 작업은 계속 표시됩니다.", + "current": "현재 동작 유지", + "after-completion": "완료 후 접기", + "always": "항상 기본으로 접기" } } diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4020ec2ed28..74fcffa6be5 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -7692,5 +7692,12 @@ "probeFailed": "O navegador integrado não conseguiu confirmar que alcança o host remoto pelo túnel. Tente novamente em instantes." } } + }, + "ChatProcessSettings": { + "title": "Detalhes do processo do chat", + "description": "Define quando recolher habilidades, comandos e chamadas de ferramentas. As respostas finais permanecem visíveis e os detalhes podem ser expandidos manualmente. Erros e interações pendentes continuam visíveis.", + "current": "Manter o comportamento atual", + "after-completion": "Recolher após a conclusão", + "always": "Sempre recolher por padrão" } } diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index aeb32c44119..bafe6e8b9c0 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -7692,5 +7692,12 @@ "probeFailed": "内置浏览器无法确认它能通过隧道访问远端主机。请稍后重试。" } } + }, + "ChatProcessSettings": { + "title": "聊天过程详情", + "description": "设置技能、命令和工具调用过程的折叠方式。最终回答始终可见,过程可手动展开;错误及待确认操作保持可见。", + "current": "执行过程全程展开", + "after-completion": "执行结束后折叠", + "always": "始终默认折叠" } } diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 034d3eeb509..427b026abbd 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -7692,5 +7692,12 @@ "probeFailed": "內建瀏覽器無法確認它能透過通道連到遠端主機。請稍後再試。" } } + }, + "ChatProcessSettings": { + "title": "聊天過程詳情", + "description": "設定技能、命令和工具呼叫過程的摺疊方式。最終回答始終可見,過程可手動展開;錯誤及待確認操作保持可見。", + "current": "保持目前行為", + "after-completion": "執行結束後摺疊", + "always": "一律預設摺疊" } } diff --git a/src/lib/chat-process-collapse.test.tsx b/src/lib/chat-process-collapse.test.tsx new file mode 100644 index 00000000000..1c4931b84b8 --- /dev/null +++ b/src/lib/chat-process-collapse.test.tsx @@ -0,0 +1,58 @@ +import { act, renderHook } from "@testing-library/react" +import { afterEach, describe, expect, it, vi } from "vitest" + +import { + getChatProcessCollapse, + setChatProcessCollapse, + useChatProcessCollapse, +} from "./chat-process-collapse" + +const KEY = "settings:chat-process-collapse:v1" + +afterEach(() => { + vi.restoreAllMocks() + localStorage.removeItem(KEY) +}) + +describe("聊天过程折叠设置", () => { + it("缺省值或损坏值均保持原有行为", () => { + expect(getChatProcessCollapse()).toBe("current") + localStorage.setItem(KEY, "invalid") + expect(getChatProcessCollapse()).toBe("current") + }) + + it("保存设置并通知当前窗口中的所有订阅者", () => { + const first = renderHook(useChatProcessCollapse) + const second = renderHook(useChatProcessCollapse) + act(() => setChatProcessCollapse("after-completion")) + expect(localStorage.getItem(KEY)).toBe("after-completion") + expect(first.result.current).toBe("after-completion") + expect(second.result.current).toBe("after-completion") + first.unmount() + second.unmount() + expect(renderHook(useChatProcessCollapse).result.current).toBe( + "after-completion" + ) + }) + + it("忽略无关设置并响应其他窗口清空存储", () => { + localStorage.setItem(KEY, "always") + const view = renderHook(useChatProcessCollapse) + act(() => + window.dispatchEvent(new StorageEvent("storage", { key: "unrelated" })) + ) + expect(view.result.current).toBe("always") + act(() => { + localStorage.removeItem(KEY) + window.dispatchEvent(new StorageEvent("storage", { key: null })) + }) + expect(view.result.current).toBe("current") + }) + + it("无法访问存储时安全回退", () => { + vi.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new Error("Storage unavailable") + }) + expect(getChatProcessCollapse()).toBe("current") + }) +}) diff --git a/src/lib/chat-process-collapse.ts b/src/lib/chat-process-collapse.ts new file mode 100644 index 00000000000..d8772e02a96 --- /dev/null +++ b/src/lib/chat-process-collapse.ts @@ -0,0 +1,55 @@ +"use client" + +import { useSyncExternalStore } from "react" + +const STORAGE_KEY = "settings:chat-process-collapse:v1" +const CHANGE_EVENT = "codeg:chat-process-collapse-changed" + +export const CHAT_PROCESS_COLLAPSE_MODES = [ + "current", + "after-completion", + "always", +] as const + +export type ChatProcessCollapseMode = + (typeof CHAT_PROCESS_COLLAPSE_MODES)[number] + +export function getChatProcessCollapse(): ChatProcessCollapseMode { + try { + const value = localStorage.getItem(STORAGE_KEY) + return value === "after-completion" || value === "always" + ? value + : "current" + } catch { + return "current" + } +} + +export function setChatProcessCollapse(mode: ChatProcessCollapseMode): void { + try { + localStorage.setItem(STORAGE_KEY, mode) + } catch { + return + } + window.dispatchEvent(new Event(CHANGE_EVENT)) +} + +function subscribe(onChange: () => void): () => void { + const onStorage = (event: StorageEvent) => { + if (event.key === null || event.key === STORAGE_KEY) onChange() + } + window.addEventListener(CHANGE_EVENT, onChange) + window.addEventListener("storage", onStorage) + return () => { + window.removeEventListener(CHANGE_EVENT, onChange) + window.removeEventListener("storage", onStorage) + } +} + +export function useChatProcessCollapse(): ChatProcessCollapseMode { + return useSyncExternalStore( + subscribe, + getChatProcessCollapse, + () => "current" + ) +}