@marimo-team/frontend 0.25.2-dev96 → 0.25.2-dev99
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/assets/{JsonOutput-NVH7GWyC.js → JsonOutput-DCGUcLVV.js} +1 -1
- package/dist/assets/{MarimoErrorOutput-z5iFryzf.js → MarimoErrorOutput-BEsxXqp6.js} +1 -1
- package/dist/assets/{agent-panel-BcoO8S6Q.js → agent-panel-BPLrf7mA.js} +1 -1
- package/dist/assets/{cell-editor-CevZDS63.js → cell-editor-C3818WlB.js} +1 -1
- package/dist/assets/chat-panel-BZipGUZV.js +4 -0
- package/dist/assets/{column-preview-sL_Jo0hX.js → column-preview-fD3qW2B5.js} +1 -1
- package/dist/assets/{command-palette-DMQFuWz1.js → command-palette-BM9fUfEe.js} +1 -1
- package/dist/assets/{dependency-graph-panel-BwMayTyZ.js → dependency-graph-panel-CLrAZrHA.js} +1 -1
- package/dist/assets/{edit-page-DD1NFkiu.js → edit-page-Ctwp3mZE.js} +4 -4
- package/dist/assets/{error-panel-ByOEJuZN.js → error-panel-BgYJTJpA.js} +1 -1
- package/dist/assets/{file-explorer-panel-B92ugwxr.js → file-explorer-panel-HvVNt0Qs.js} +1 -1
- package/dist/assets/{hooks-DNPsQf79.js → hooks-Qxjt0Ypd.js} +1 -1
- package/dist/assets/{index-C6-WDESh.js → index-B3pNgH5o.js} +2 -2
- package/dist/assets/index-CXX6l_22.css +2 -0
- package/dist/assets/{layout-BaluekG6.js → layout-CSLw6wGo.js} +3 -3
- package/dist/assets/{panels-CO6Clgel.js → panels-BdsXFz1w.js} +1 -1
- package/dist/assets/{reveal-component-DeZTBbnn.js → reveal-component-B5GUiD5Q.js} +1 -1
- package/dist/assets/{run-page-BNEg-Ro9.js → run-page-CVWospEP.js} +1 -1
- package/dist/assets/{scratchpad-panel-lDJWcpVs.js → scratchpad-panel-B4qlmwP-.js} +1 -1
- package/dist/assets/{session-panel-BqutyoYd.js → session-panel-Ch5jQNj0.js} +1 -1
- package/dist/assets/state-BI0fn_YF.js +1 -0
- package/dist/assets/{useCellActionButton-DwAUf_Cn.js → useCellActionButton-BTsPn8Dv.js} +1 -1
- package/dist/assets/{useDependencyPanelTab-CbcGEmqM.js → useDependencyPanelTab-BTsnw2rN.js} +1 -1
- package/dist/assets/{useNotebookActions-pXzVjrlk.js → useNotebookActions-BzJtySGp.js} +1 -1
- package/dist/index.html +6 -6
- package/package.json +3 -2
- package/src/components/chat/__tests__/chat-history-popover.test.tsx +157 -0
- package/src/components/chat/chat-history-popover.tsx +98 -17
- package/src/components/chat/chat-panel.tsx +29 -0
- package/src/core/ai/__tests__/state.test.ts +120 -0
- package/src/core/ai/state.ts +36 -10
- package/src/css/katex-css.test.ts +166 -0
- package/src/css/katex.min.css +27 -27
- package/dist/assets/chat-panel-DyaePe7B.js +0 -4
- package/dist/assets/index-B7ZZVUcj.css +0 -2
- package/dist/assets/state-B85OOscw.js +0 -1
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
2
|
|
|
3
3
|
import { useAtomValue } from "jotai";
|
|
4
|
-
import {
|
|
5
|
-
|
|
4
|
+
import {
|
|
5
|
+
BotMessageSquareIcon,
|
|
6
|
+
ClockIcon,
|
|
7
|
+
SearchIcon,
|
|
8
|
+
Trash2Icon,
|
|
9
|
+
} from "lucide-react";
|
|
10
|
+
import { useMemo, useRef, useState } from "react";
|
|
6
11
|
import { useLocale } from "react-aria";
|
|
12
|
+
import { useImperativeModal } from "@/components/modal/ImperativeModal";
|
|
13
|
+
import { AlertDialogDestructiveAction } from "@/components/ui/alert-dialog";
|
|
7
14
|
import { Button } from "@/components/ui/button";
|
|
8
15
|
import { Input } from "@/components/ui/input";
|
|
9
16
|
import {
|
|
@@ -19,18 +26,48 @@ import { timeAgo } from "@/utils/dates";
|
|
|
19
26
|
import { PanelEmptyState } from "../editor/chrome/panels/empty-state";
|
|
20
27
|
import { groupChatsByDate } from "./chat-history-utils";
|
|
21
28
|
|
|
29
|
+
const CHAT_HISTORY_WARNING_THRESHOLD = 50;
|
|
30
|
+
|
|
22
31
|
interface ChatHistoryPopoverProps {
|
|
23
32
|
activeChatId: ChatId | undefined;
|
|
24
33
|
setActiveChat: (id: ChatId | null) => void;
|
|
34
|
+
onDeleteChats: (id: ChatId | null) => void;
|
|
35
|
+
isLoading: boolean;
|
|
25
36
|
}
|
|
26
37
|
|
|
27
38
|
export const ChatHistoryPopover: React.FC<ChatHistoryPopoverProps> = ({
|
|
28
39
|
activeChatId,
|
|
29
40
|
setActiveChat,
|
|
41
|
+
onDeleteChats,
|
|
42
|
+
isLoading,
|
|
30
43
|
}) => {
|
|
31
44
|
const chatState = useAtomValue(chatStateAtom);
|
|
32
45
|
const { locale } = useLocale();
|
|
33
46
|
const [searchQuery, setSearchQuery] = useState("");
|
|
47
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
48
|
+
const [open, setOpen] = useState(false);
|
|
49
|
+
const { openConfirm, closeModal } = useImperativeModal();
|
|
50
|
+
|
|
51
|
+
const confirmDeleteAllChats = () => {
|
|
52
|
+
// Close the popover before opening a modal so focus moves to the dialog.
|
|
53
|
+
setOpen(false);
|
|
54
|
+
openConfirm({
|
|
55
|
+
title: "Delete all chats?",
|
|
56
|
+
description:
|
|
57
|
+
"This will permanently delete all chat history for all notebooks on this site in this browser. This cannot be undone.",
|
|
58
|
+
variant: "destructive",
|
|
59
|
+
confirmAction: (
|
|
60
|
+
<AlertDialogDestructiveAction
|
|
61
|
+
onClick={() => {
|
|
62
|
+
onDeleteChats(null);
|
|
63
|
+
closeModal();
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
Delete all chats
|
|
67
|
+
</AlertDialogDestructiveAction>
|
|
68
|
+
),
|
|
69
|
+
});
|
|
70
|
+
};
|
|
34
71
|
|
|
35
72
|
const chats = useMemo(() => {
|
|
36
73
|
return [...chatState.chats.values()].toSorted(
|
|
@@ -52,10 +89,10 @@ export const ChatHistoryPopover: React.FC<ChatHistoryPopoverProps> = ({
|
|
|
52
89
|
}, [filteredChats]);
|
|
53
90
|
|
|
54
91
|
return (
|
|
55
|
-
<Popover>
|
|
92
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
56
93
|
<Tooltip content="Previous chats">
|
|
57
94
|
<PopoverTrigger asChild={true}>
|
|
58
|
-
<Button variant="text" size="icon">
|
|
95
|
+
<Button variant="text" size="icon" aria-label="Previous chats">
|
|
59
96
|
<ClockIcon className="h-4 w-4" />
|
|
60
97
|
</Button>
|
|
61
98
|
</PopoverTrigger>
|
|
@@ -63,6 +100,7 @@ export const ChatHistoryPopover: React.FC<ChatHistoryPopoverProps> = ({
|
|
|
63
100
|
<PopoverContent className="w-[480px] p-0" align="start" side="right">
|
|
64
101
|
<div className="pt-3 px-3 w-full">
|
|
65
102
|
<Input
|
|
103
|
+
ref={searchInputRef}
|
|
66
104
|
placeholder="Search chat history..."
|
|
67
105
|
value={searchQuery}
|
|
68
106
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
@@ -92,25 +130,46 @@ export const ChatHistoryPopover: React.FC<ChatHistoryPopoverProps> = ({
|
|
|
92
130
|
</div>
|
|
93
131
|
<div>
|
|
94
132
|
{group.chats.map((chat) => (
|
|
95
|
-
<
|
|
133
|
+
<div
|
|
96
134
|
key={chat.id}
|
|
97
135
|
className={cn(
|
|
98
|
-
"w-full
|
|
136
|
+
"w-full rounded-md flex items-center justify-between",
|
|
99
137
|
chat.id === activeChatId && "bg-accent",
|
|
100
138
|
chat.id !== activeChatId && "hover:bg-muted/20",
|
|
101
139
|
)}
|
|
102
|
-
onClick={() => {
|
|
103
|
-
setActiveChat(chat.id);
|
|
104
|
-
}}
|
|
105
|
-
type="button"
|
|
106
140
|
>
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
141
|
+
<button
|
|
142
|
+
className="flex flex-1 min-w-0 items-center justify-between p-1 text-left cursor-pointer"
|
|
143
|
+
onClick={() => setActiveChat(chat.id)}
|
|
144
|
+
type="button"
|
|
145
|
+
>
|
|
146
|
+
<span className="text-sm truncate">{chat.title}</span>
|
|
147
|
+
<span className="text-xs text-muted-foreground/60 ml-2 shrink-0">
|
|
148
|
+
{timeAgo(chat.updatedAt, locale)}
|
|
149
|
+
</span>
|
|
150
|
+
</button>
|
|
151
|
+
<Tooltip
|
|
152
|
+
content={
|
|
153
|
+
isLoading && chat.id === activeChatId
|
|
154
|
+
? "Stop the response before deleting this chat"
|
|
155
|
+
: "Delete chat"
|
|
156
|
+
}
|
|
157
|
+
>
|
|
158
|
+
<Button
|
|
159
|
+
variant="text"
|
|
160
|
+
size="icon"
|
|
161
|
+
className="text-muted-foreground/90 hover:text-destructive transition-colors ml-px"
|
|
162
|
+
aria-label={`Delete chat: ${chat.title}`}
|
|
163
|
+
disabled={isLoading && chat.id === activeChatId}
|
|
164
|
+
onClick={() => {
|
|
165
|
+
searchInputRef.current?.focus();
|
|
166
|
+
onDeleteChats(chat.id);
|
|
167
|
+
}}
|
|
168
|
+
>
|
|
169
|
+
<Trash2Icon className="h-3.5 w-3.5" />
|
|
170
|
+
</Button>
|
|
171
|
+
</Tooltip>
|
|
172
|
+
</div>
|
|
114
173
|
))}
|
|
115
174
|
</div>
|
|
116
175
|
{/* If last group, don't show a divider */}
|
|
@@ -119,6 +178,28 @@ export const ChatHistoryPopover: React.FC<ChatHistoryPopoverProps> = ({
|
|
|
119
178
|
))}
|
|
120
179
|
</div>
|
|
121
180
|
</ScrollArea>
|
|
181
|
+
{chats.length > 0 && (
|
|
182
|
+
<div className="border-t p-2 flex items-center justify-between gap-2">
|
|
183
|
+
{chats.length > CHAT_HISTORY_WARNING_THRESHOLD ? (
|
|
184
|
+
<p className="text-xs text-muted-foreground">
|
|
185
|
+
You have over <b>{CHAT_HISTORY_WARNING_THRESHOLD}</b> chats.
|
|
186
|
+
Delete old chats to free up browser storage.
|
|
187
|
+
</p>
|
|
188
|
+
) : (
|
|
189
|
+
<p className="text-xs text-muted-foreground">
|
|
190
|
+
Chats are shared across notebooks.
|
|
191
|
+
</p>
|
|
192
|
+
)}
|
|
193
|
+
<Button
|
|
194
|
+
variant="linkDestructive"
|
|
195
|
+
size="xs"
|
|
196
|
+
disabled={isLoading}
|
|
197
|
+
onClick={confirmDeleteAllChats}
|
|
198
|
+
>
|
|
199
|
+
Delete all chats
|
|
200
|
+
</Button>
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
122
203
|
</PopoverContent>
|
|
123
204
|
</Popover>
|
|
124
205
|
);
|
|
@@ -150,12 +150,16 @@ interface ChatHeaderProps {
|
|
|
150
150
|
onNewChat: () => void;
|
|
151
151
|
activeChatId: ChatId | undefined;
|
|
152
152
|
setActiveChat: (id: ChatId | null) => void;
|
|
153
|
+
onDeleteChats: (id: ChatId | null) => void;
|
|
154
|
+
isLoading: boolean;
|
|
153
155
|
}
|
|
154
156
|
|
|
155
157
|
const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|
156
158
|
onNewChat,
|
|
157
159
|
activeChatId,
|
|
158
160
|
setActiveChat,
|
|
161
|
+
onDeleteChats,
|
|
162
|
+
isLoading,
|
|
159
163
|
}) => {
|
|
160
164
|
const { handleClick } = useOpenSettingsToTab();
|
|
161
165
|
|
|
@@ -184,6 +188,8 @@ const ChatHeader: React.FC<ChatHeaderProps> = ({
|
|
|
184
188
|
<ChatHistoryPopover
|
|
185
189
|
activeChatId={activeChatId}
|
|
186
190
|
setActiveChat={setActiveChat}
|
|
191
|
+
onDeleteChats={onDeleteChats}
|
|
192
|
+
isLoading={isLoading}
|
|
187
193
|
/>
|
|
188
194
|
</div>
|
|
189
195
|
</div>
|
|
@@ -838,6 +844,27 @@ const ChatPanelBody = () => {
|
|
|
838
844
|
clearFiles();
|
|
839
845
|
});
|
|
840
846
|
|
|
847
|
+
const handleDeleteChats = useEvent((id: ChatId | null) => {
|
|
848
|
+
if (id === null || id === activeChatId) {
|
|
849
|
+
handleNewChat();
|
|
850
|
+
}
|
|
851
|
+
setChatState((prev) => {
|
|
852
|
+
const chats = new Map(prev.chats);
|
|
853
|
+
if (id === null) {
|
|
854
|
+
chats.clear();
|
|
855
|
+
} else {
|
|
856
|
+
chats.delete(id);
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
chats,
|
|
860
|
+
activeChatId:
|
|
861
|
+
prev.activeChatId && chats.has(prev.activeChatId)
|
|
862
|
+
? prev.activeChatId
|
|
863
|
+
: null,
|
|
864
|
+
};
|
|
865
|
+
});
|
|
866
|
+
});
|
|
867
|
+
|
|
841
868
|
const handlePairWithAgent = useEvent(() => {
|
|
842
869
|
openModal(<PairWithAgentModal onClose={closeModal} />);
|
|
843
870
|
});
|
|
@@ -997,6 +1024,8 @@ const ChatPanelBody = () => {
|
|
|
997
1024
|
onNewChat={handleNewChat}
|
|
998
1025
|
activeChatId={activeChat?.id}
|
|
999
1026
|
setActiveChat={setActiveChat}
|
|
1027
|
+
onDeleteChats={handleDeleteChats}
|
|
1028
|
+
isLoading={isLoading}
|
|
1000
1029
|
/>
|
|
1001
1030
|
</TooltipProvider>
|
|
1002
1031
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { createStore } from "jotai";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import { toast } from "@/components/ui/use-toast";
|
|
6
|
+
import { type Chat, type ChatId, chatStateAtom } from "../state";
|
|
7
|
+
|
|
8
|
+
vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() }));
|
|
9
|
+
|
|
10
|
+
const KEY = "marimo:ai:chatState:v5";
|
|
11
|
+
const chatId = "saved-chat" as ChatId;
|
|
12
|
+
const chat: Chat = {
|
|
13
|
+
id: chatId,
|
|
14
|
+
title: "Saved conversation",
|
|
15
|
+
messages: [
|
|
16
|
+
{ id: "message", role: "user", parts: [{ type: "text", text: "Hello" }] },
|
|
17
|
+
],
|
|
18
|
+
createdAt: 1,
|
|
19
|
+
updatedAt: 1,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const unsubscribers: (() => void)[] = [];
|
|
23
|
+
|
|
24
|
+
function loadStore() {
|
|
25
|
+
const store = createStore();
|
|
26
|
+
unsubscribers.push(store.sub(chatStateAtom, vi.fn()));
|
|
27
|
+
return store;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fillStorage() {
|
|
31
|
+
vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => {
|
|
32
|
+
throw new DOMException("Storage is full", "QuotaExceededError");
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("chat history storage", () => {
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
window.localStorage.clear();
|
|
39
|
+
window.localStorage.setItem(
|
|
40
|
+
KEY,
|
|
41
|
+
JSON.stringify({
|
|
42
|
+
chats: [[chatId, chat]],
|
|
43
|
+
activeChatId: chatId,
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
afterEach(() => {
|
|
49
|
+
for (const unsubscribe of unsubscribers.splice(0)) {
|
|
50
|
+
unsubscribe();
|
|
51
|
+
}
|
|
52
|
+
vi.restoreAllMocks();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("loads saved history when storage is full", () => {
|
|
56
|
+
fillStorage();
|
|
57
|
+
expect(loadStore().get(chatStateAtom)).toEqual({
|
|
58
|
+
chats: new Map([[chatId, chat]]),
|
|
59
|
+
activeChatId: chatId,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("warns on save failure without interrupting in-memory updates", () => {
|
|
64
|
+
const store = loadStore();
|
|
65
|
+
const savedHistory = window.localStorage.getItem(KEY);
|
|
66
|
+
fillStorage();
|
|
67
|
+
const updatedChat = { ...chat, title: "Unsaved conversation" };
|
|
68
|
+
expect(() =>
|
|
69
|
+
store.set(chatStateAtom, {
|
|
70
|
+
chats: new Map([[chatId, updatedChat]]),
|
|
71
|
+
activeChatId: chatId,
|
|
72
|
+
}),
|
|
73
|
+
).not.toThrow();
|
|
74
|
+
|
|
75
|
+
expect(store.get(chatStateAtom).chats.get(chatId)).toEqual(updatedChat);
|
|
76
|
+
expect(window.localStorage.getItem(KEY)).toBe(savedHistory);
|
|
77
|
+
expect(toast).toHaveBeenCalledWith(
|
|
78
|
+
expect.objectContaining({
|
|
79
|
+
id: "chat-storage-error",
|
|
80
|
+
once: true,
|
|
81
|
+
title: "Chat history couldn't be saved",
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("frees storage when history is cleared even if further writes fail", () => {
|
|
87
|
+
window.localStorage.setItem("unrelated-setting", "keep");
|
|
88
|
+
const store = loadStore();
|
|
89
|
+
fillStorage();
|
|
90
|
+
store.set(chatStateAtom, { chats: new Map(), activeChatId: null });
|
|
91
|
+
|
|
92
|
+
expect(window.localStorage.getItem(KEY)).toBeNull();
|
|
93
|
+
expect(window.localStorage.getItem("unrelated-setting")).toBe("keep");
|
|
94
|
+
expect(loadStore().get(chatStateAtom)).toEqual({
|
|
95
|
+
chats: new Map(),
|
|
96
|
+
activeChatId: null,
|
|
97
|
+
});
|
|
98
|
+
expect(toast).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("removes saved history when only an empty chat remains and writes fail", () => {
|
|
102
|
+
const store = loadStore();
|
|
103
|
+
const emptyChat = { ...chat, messages: [] };
|
|
104
|
+
const state = {
|
|
105
|
+
chats: new Map([[chatId, emptyChat]]),
|
|
106
|
+
activeChatId: chatId,
|
|
107
|
+
};
|
|
108
|
+
fillStorage();
|
|
109
|
+
|
|
110
|
+
store.set(chatStateAtom, state);
|
|
111
|
+
|
|
112
|
+
expect(window.localStorage.getItem(KEY)).toBeNull();
|
|
113
|
+
expect(store.get(chatStateAtom)).toEqual(state);
|
|
114
|
+
expect(loadStore().get(chatStateAtom)).toEqual({
|
|
115
|
+
chats: new Map(),
|
|
116
|
+
activeChatId: null,
|
|
117
|
+
});
|
|
118
|
+
expect(toast).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
});
|
package/src/core/ai/state.ts
CHANGED
|
@@ -4,7 +4,9 @@ import type { UIMessage } from "@ai-sdk/react";
|
|
|
4
4
|
import type { FileUIPart } from "ai";
|
|
5
5
|
import { atom } from "jotai";
|
|
6
6
|
import { atomWithStorage } from "jotai/utils";
|
|
7
|
+
import { toast } from "@/components/ui/use-toast";
|
|
7
8
|
import { uniqueBy } from "@/utils/arrays";
|
|
9
|
+
import { Logger } from "@/utils/Logger";
|
|
8
10
|
import { adaptForLocalStorage, jotaiJsonStorage } from "@/utils/storage/jotai";
|
|
9
11
|
import type { TypedString } from "@/utils/typed";
|
|
10
12
|
import type { CellId } from "../cells/ids";
|
|
@@ -85,22 +87,46 @@ function removeEmptyChats(chatState: Map<ChatId, Chat>): Map<ChatId, Chat> {
|
|
|
85
87
|
return result;
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
const chatStorage = adaptForLocalStorage({
|
|
91
|
+
toSerializable: (value: ChatState) => ({
|
|
92
|
+
chats: [...removeEmptyChats(value.chats).entries()],
|
|
93
|
+
activeChatId: value.activeChatId,
|
|
94
|
+
}),
|
|
95
|
+
fromSerializable: (value) => ({
|
|
96
|
+
chats: new Map(value.chats),
|
|
97
|
+
activeChatId: value.activeChatId,
|
|
98
|
+
}),
|
|
99
|
+
});
|
|
100
|
+
|
|
88
101
|
export const chatStateAtom = atomWithStorage<ChatState>(
|
|
89
102
|
KEY,
|
|
90
103
|
{
|
|
91
104
|
chats: new Map(),
|
|
92
105
|
activeChatId: null,
|
|
93
106
|
},
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
107
|
+
{
|
|
108
|
+
...chatStorage,
|
|
109
|
+
setItem: (key, value) => {
|
|
110
|
+
try {
|
|
111
|
+
// Removing the key frees space even when no further writes can fit.
|
|
112
|
+
if (removeEmptyChats(value.chats).size === 0) {
|
|
113
|
+
chatStorage.removeItem(key);
|
|
114
|
+
} else {
|
|
115
|
+
chatStorage.setItem(key, value);
|
|
116
|
+
}
|
|
117
|
+
} catch (error) {
|
|
118
|
+
Logger.warn("Could not save chat history", error);
|
|
119
|
+
toast({
|
|
120
|
+
id: "chat-storage-error",
|
|
121
|
+
once: true,
|
|
122
|
+
title: "Chat history couldn't be saved",
|
|
123
|
+
description:
|
|
124
|
+
"Try deleting old chats. New messages may be lost when you reload.",
|
|
125
|
+
variant: "danger",
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
},
|
|
104
130
|
);
|
|
105
131
|
|
|
106
132
|
export const activeChatAtom = atom(
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
import { readFile } from "node:fs/promises";
|
|
4
|
+
import postcss from "postcss";
|
|
5
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
6
|
+
import {
|
|
7
|
+
checkKatexCss,
|
|
8
|
+
generateKatexCss,
|
|
9
|
+
readUpstreamCss,
|
|
10
|
+
stylesheetPath,
|
|
11
|
+
} from "../../scripts/katex-css";
|
|
12
|
+
|
|
13
|
+
let upstream: string;
|
|
14
|
+
let generated: string;
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
upstream = await readUpstreamCss();
|
|
18
|
+
generated = await generateKatexCss(upstream);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe("copied KaTeX stylesheet", () => {
|
|
22
|
+
it("matches the installed package with only documented marimo overrides", async () => {
|
|
23
|
+
await expect(
|
|
24
|
+
checkKatexCss(upstream, await readFile(stylesheetPath, "utf8")),
|
|
25
|
+
).resolves.toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it.each(["added", "removed", "changed"])(
|
|
29
|
+
"detects %s upstream rule without a version change",
|
|
30
|
+
async (change) => {
|
|
31
|
+
const root = postcss.parse(upstream);
|
|
32
|
+
if (change === "added") {
|
|
33
|
+
root.append(
|
|
34
|
+
postcss.parse(
|
|
35
|
+
".katex .reflectbox { display: inline-block; transform: scaleX(-1); }",
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
} else {
|
|
39
|
+
root.walkRules(".katex .mspace", (rule) => {
|
|
40
|
+
if (change === "removed") {
|
|
41
|
+
rule.remove();
|
|
42
|
+
} else {
|
|
43
|
+
rule.append(postcss.decl({ prop: "color", value: "red" }));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
await expect(checkKatexCss(root.toString(), generated)).rejects.toThrow(
|
|
48
|
+
"pnpm sync-katex-css",
|
|
49
|
+
);
|
|
50
|
+
const regenerated = await generateKatexCss(root.toString());
|
|
51
|
+
await checkKatexCss(root.toString(), regenerated);
|
|
52
|
+
},
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
it.each(["added", "removed", "changed"])(
|
|
56
|
+
"detects %s local rule",
|
|
57
|
+
async (change) => {
|
|
58
|
+
const root = postcss.parse(generated);
|
|
59
|
+
if (change === "added") {
|
|
60
|
+
root.append(postcss.parse(".katex .unexpected { display: none; }"));
|
|
61
|
+
} else {
|
|
62
|
+
root.walkRules(".katex .mspace", (rule) => {
|
|
63
|
+
if (change === "removed") {
|
|
64
|
+
rule.remove();
|
|
65
|
+
} else {
|
|
66
|
+
rule.append(postcss.decl({ prop: "color", value: "red" }));
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
await expect(checkKatexCss(upstream, root.toString())).rejects.toThrow(
|
|
71
|
+
"pnpm sync-katex-css",
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
it("preserves math sizing, tag alignment, and equation counters without bundling font faces", () => {
|
|
77
|
+
const root = postcss.parse(generated);
|
|
78
|
+
const declarations = (selector: string) => {
|
|
79
|
+
const values: Record<string, string> = {};
|
|
80
|
+
root.walkRules(selector, (rule) => {
|
|
81
|
+
rule.walkDecls((decl) => {
|
|
82
|
+
values[decl.prop] = decl.value;
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
return values;
|
|
86
|
+
};
|
|
87
|
+
expect(declarations(".katex")).toEqual({
|
|
88
|
+
"font-family": '"KaTeX_Main", "Times New Roman", serif',
|
|
89
|
+
"font-size": "110%",
|
|
90
|
+
"font-style": "normal",
|
|
91
|
+
"line-height": "inherit",
|
|
92
|
+
position: "relative",
|
|
93
|
+
"text-indent": "0",
|
|
94
|
+
"text-rendering": "auto",
|
|
95
|
+
});
|
|
96
|
+
expect(
|
|
97
|
+
declarations(".katex-display > .katex > .katex-html > .katex-tag"),
|
|
98
|
+
).toEqual({ position: "absolute", right: "0", "text-align": "right" });
|
|
99
|
+
expect(declarations("body,\n.katex-display")).toEqual({
|
|
100
|
+
"counter-reset": "katexEqnNo mmlEqnNo",
|
|
101
|
+
});
|
|
102
|
+
expect(declarations(".katex .angl")["border-right"]).toBe("0.049em solid");
|
|
103
|
+
const fontFaces: postcss.AtRule[] = [];
|
|
104
|
+
root.walkAtRules("font-face", (rule) => {
|
|
105
|
+
fontFaces.push(rule);
|
|
106
|
+
});
|
|
107
|
+
expect(fontFaces).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it.each(["root", "media"])(
|
|
111
|
+
"preserves SVG rule order and %s scope when upstream adds a competing rule",
|
|
112
|
+
async (scope) => {
|
|
113
|
+
const root = postcss.parse(upstream);
|
|
114
|
+
const svgRules: postcss.Rule[] = [];
|
|
115
|
+
root.walkRules(".katex svg", (rule) => {
|
|
116
|
+
svgRules.push(rule);
|
|
117
|
+
});
|
|
118
|
+
const secondSvg = svgRules[1];
|
|
119
|
+
secondSvg.before(postcss.parse(".katex :is(svg) { stroke-width: 3; }"));
|
|
120
|
+
if (scope === "media") {
|
|
121
|
+
const media = postcss.atRule({ name: "media", params: "print" });
|
|
122
|
+
secondSvg.before(media);
|
|
123
|
+
media.append(secondSvg);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const svgCascade = (css: string) => {
|
|
127
|
+
const rules: {
|
|
128
|
+
selector: string;
|
|
129
|
+
scope: string;
|
|
130
|
+
strokeWidth: string | undefined;
|
|
131
|
+
}[] = [];
|
|
132
|
+
postcss.parse(css).walkRules((rule) => {
|
|
133
|
+
if (
|
|
134
|
+
rule.selector !== ".katex svg" &&
|
|
135
|
+
rule.selector !== ".katex :is(svg)"
|
|
136
|
+
) {
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
let strokeWidth: string | undefined;
|
|
140
|
+
rule.walkDecls("stroke-width", (decl) => {
|
|
141
|
+
strokeWidth = decl.value;
|
|
142
|
+
});
|
|
143
|
+
const parent = rule.parent;
|
|
144
|
+
rules.push({
|
|
145
|
+
selector: rule.selector,
|
|
146
|
+
scope:
|
|
147
|
+
parent?.type === "atrule"
|
|
148
|
+
? `${parent.name} ${parent.params}`
|
|
149
|
+
: "root",
|
|
150
|
+
strokeWidth,
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
return rules;
|
|
154
|
+
};
|
|
155
|
+
const regenerated = await generateKatexCss(root.toString());
|
|
156
|
+
expect(svgCascade(regenerated)).toEqual(svgCascade(root.toString()));
|
|
157
|
+
await checkKatexCss(root.toString(), regenerated);
|
|
158
|
+
},
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
it("requires review when an overridden upstream declaration changes", async () => {
|
|
162
|
+
await expect(
|
|
163
|
+
generateKatexCss(upstream.replace("line-height:1.2", "line-height:1.3")),
|
|
164
|
+
).rejects.toThrow("review overrides");
|
|
165
|
+
});
|
|
166
|
+
});
|
package/src/css/katex.min.css
CHANGED
|
@@ -1,23 +1,20 @@
|
|
|
1
1
|
/*
|
|
2
|
-
*
|
|
2
|
+
* Generated from the installed katex/dist/katex.min.css.
|
|
3
|
+
* Run `pnpm sync-katex-css` from the repository root or frontend to regenerate.
|
|
4
|
+
* Do not edit this file directly; update scripts/katex-css.ts for local overrides.
|
|
3
5
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* `font-style: normal` in place of the upstream `font` shorthand, so math
|
|
17
|
-
* follows the surrounding text size and stays upright in blockquotes.
|
|
18
|
-
* - `.katex-tag` inside `.katex-display`: `text-align: right`.
|
|
19
|
-
* - `counter-reset` on `.katex-display` as well as `body`, so equation
|
|
20
|
-
* numbers restart for each display block.
|
|
6
|
+
* Documented marimo overrides:
|
|
7
|
+
* - Remove @font-face: font declarations live in katex-fonts.css.
|
|
8
|
+
* - Preserve em units so scripts, rules, and spacing scale with surrounding math.
|
|
9
|
+
* - Replace .katex's font shorthand with its font-family, font-size: 110%,
|
|
10
|
+
* font-style: normal, and line-height: inherit to follow surrounding text size
|
|
11
|
+
* and keep math upright in blockquotes.
|
|
12
|
+
* - Right-align display equation tags.
|
|
13
|
+
* - Reset equation counters on each display block as well as body.
|
|
14
|
+
* - Preserve duplicate SVG rules in their upstream order and scope, with a
|
|
15
|
+
* targeted stylelint exemption so their cascade remains intact.
|
|
16
|
+
* Formatting and syntax normalization use oxfmt and stylelint; adjacent exact
|
|
17
|
+
* duplicate declarations left by vendor-prefix normalization are removed.
|
|
21
18
|
*/
|
|
22
19
|
|
|
23
20
|
/* stylelint-disable unit-allowed-list -- KaTeX sizes are relative to the math. */
|
|
@@ -1002,6 +999,17 @@
|
|
|
1002
999
|
.katex svg {
|
|
1003
1000
|
fill: currentcolor;
|
|
1004
1001
|
stroke: currentcolor;
|
|
1002
|
+
display: block;
|
|
1003
|
+
height: inherit;
|
|
1004
|
+
position: absolute;
|
|
1005
|
+
width: 100%;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
.katex svg path {
|
|
1009
|
+
stroke: none;
|
|
1010
|
+
}
|
|
1011
|
+
/* stylelint-disable no-duplicate-selectors -- Preserve the upstream SVG cascade. */
|
|
1012
|
+
.katex svg {
|
|
1005
1013
|
fill-rule: nonzero;
|
|
1006
1014
|
fill-opacity: 1;
|
|
1007
1015
|
stroke-width: 1;
|
|
@@ -1011,16 +1019,8 @@
|
|
|
1011
1019
|
stroke-dasharray: none;
|
|
1012
1020
|
stroke-dashoffset: 0;
|
|
1013
1021
|
stroke-opacity: 1;
|
|
1014
|
-
display: block;
|
|
1015
|
-
height: inherit;
|
|
1016
|
-
position: absolute;
|
|
1017
|
-
width: 100%;
|
|
1018
1022
|
}
|
|
1019
|
-
|
|
1020
|
-
.katex svg path {
|
|
1021
|
-
stroke: none;
|
|
1022
|
-
}
|
|
1023
|
-
|
|
1023
|
+
/* stylelint-enable no-duplicate-selectors */
|
|
1024
1024
|
.katex img {
|
|
1025
1025
|
border-style: none;
|
|
1026
1026
|
max-height: none;
|