@marimo-team/frontend 0.15.1-dev13 → 0.15.1-dev15

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.
Files changed (106) hide show
  1. package/dist/assets/{ConnectedDataExplorerComponent-BvA88co5.js → ConnectedDataExplorerComponent-DBUd_daZ.js} +1 -1
  2. package/dist/assets/{ImageComparisonComponent-DMstqRhm.js → ImageComparisonComponent-TQLjl8-A.js} +1 -1
  3. package/dist/assets/{VegaLite-Mesl0wXW.js → VegaLite-yhYi0HeN.js} +1 -1
  4. package/dist/assets/{_baseEach-4LREA2xJ.js → _baseEach-JKpuQGGw.js} +1 -1
  5. package/dist/assets/_baseMap-DfLCvaTE.js +1 -0
  6. package/dist/assets/{_baseUniq-C4wq7Gz_.js → _baseUniq-oc_6YPf-.js} +1 -1
  7. package/dist/assets/{_createAggregator-Di1sij95.js → _createAggregator-CjX0mq3Q.js} +1 -1
  8. package/dist/assets/{any-language-editor-CNdroSlJ.js → any-language-editor-CdJsYYUF.js} +1 -1
  9. package/dist/assets/{architectureDiagram-KFL7JDKH-BxXZfRHj.js → architectureDiagram-KFL7JDKH-sqjo2uGH.js} +1 -1
  10. package/dist/assets/{blockDiagram-ZYB65J3Q-8mg_ND0j.js → blockDiagram-ZYB65J3Q-BtBIDQtG.js} +1 -1
  11. package/dist/assets/{c4Diagram-AAMF2YG6-BH1zFYgt.js → c4Diagram-AAMF2YG6-DthVqeQN.js} +1 -1
  12. package/dist/assets/channel-DNnTCi_p.js +1 -0
  13. package/dist/assets/{chunk-ANTBXLJU-wUIE1C6t.js → chunk-ANTBXLJU-DF66s78l.js} +1 -1
  14. package/dist/assets/{chunk-FHKO5MBM-BnGig1B_.js → chunk-FHKO5MBM-CVTCXRcr.js} +1 -1
  15. package/dist/assets/{chunk-GLLZNHP4-BeuHRwGr.js → chunk-GLLZNHP4-qNdWR7gQ.js} +1 -1
  16. package/dist/assets/{chunk-JBRWN2VN-BEuqVuwn.js → chunk-JBRWN2VN-BZBG0uRP.js} +1 -1
  17. package/dist/assets/{chunk-LXBSTHXV-DIaccDrz.js → chunk-LXBSTHXV-D_Ut6UPx.js} +1 -1
  18. package/dist/assets/{chunk-NRVI72HA-I0EqNhLo.js → chunk-NRVI72HA-DDaBcZOX.js} +1 -1
  19. package/dist/assets/{chunk-OMD6QJNC-DBDKfLns.js → chunk-OMD6QJNC-CLpp5MWy.js} +1 -1
  20. package/dist/assets/{chunk-WVR4S24B-D9vksrD4.js → chunk-WVR4S24B-CQnZmrIw.js} +1 -1
  21. package/dist/assets/{circle-play-Xdbicaqg.js → circle-play-9Xd-gZEh.js} +1 -1
  22. package/dist/assets/classDiagram-3BZAVTQC-BDxOXI6m.js +1 -0
  23. package/dist/assets/classDiagram-v2-QTMF73CY-BDxOXI6m.js +1 -0
  24. package/dist/assets/clone-De7JtMAH.js +1 -0
  25. package/dist/assets/{compile-D-SKgFzl.js → compile-BGv_ojyk.js} +1 -1
  26. package/dist/assets/{dagre-2BBEFEWP-Dj4VJDXi.js → dagre-2BBEFEWP-mQCfVrx9.js} +1 -1
  27. package/dist/assets/{data-grid-overlay-editor-BHUr5Xhc.js → data-grid-overlay-editor-D5oJExBW.js} +1 -1
  28. package/dist/assets/{diagram-4IRLE6MV-9wayoyRN.js → diagram-4IRLE6MV-BwGpjy4g.js} +1 -1
  29. package/dist/assets/{diagram-GUPCWM2R-5NhVlGLr.js → diagram-GUPCWM2R-Cxhb1MRk.js} +1 -1
  30. package/dist/assets/{diagram-RP2FKANI-CNBOj37W.js → diagram-RP2FKANI-DKYRGqy9.js} +1 -1
  31. package/dist/assets/{edit-page-D8Xj7wsn.js → edit-page-D681Xko0.js} +56 -56
  32. package/dist/assets/{erDiagram-HZWUO2LU-KEjz95KS.js → erDiagram-HZWUO2LU-B01RHH3V.js} +1 -1
  33. package/dist/assets/{flowDiagram-THRYKUMA-9PtbKpm0.js → flowDiagram-THRYKUMA-CHpgKxF2.js} +1 -1
  34. package/dist/assets/{ganttDiagram-WV7ZQ7D5-CFXRYmNt.js → ganttDiagram-WV7ZQ7D5-B3m0VvI_.js} +1 -1
  35. package/dist/assets/{gitGraphDiagram-OJR772UL-hFrAcG26.js → gitGraphDiagram-OJR772UL-CSewrxl-.js} +1 -1
  36. package/dist/assets/{glide-data-editor-HLSiEHg4.js → glide-data-editor-NsBWrkHW.js} +4 -4
  37. package/dist/assets/{graph-iTnwmAye.js → graph-CyufHn4v.js} +1 -1
  38. package/dist/assets/{home-page-DzRg-dzP.js → home-page-BX9IF9X8.js} +1 -1
  39. package/dist/assets/{index-DNVhZM1y.js → index-35cprCrn.js} +1 -1
  40. package/dist/assets/{index-BzZRHBMs.js → index-B7EGTfqm.js} +1 -1
  41. package/dist/assets/{index-WZpn7zIz.js → index-BYvjPCi2.js} +12 -12
  42. package/dist/assets/{index-CEoUSuyW.js → index-BjRI1kuK.js} +1 -1
  43. package/dist/assets/{index-Cl5g0z7j.js → index-Bk4AL6s6.js} +1 -1
  44. package/dist/assets/{index-D1rqBcHl.js → index-BytK-SM5.js} +1 -1
  45. package/dist/assets/index-CJDYNzWr.css +1 -0
  46. package/dist/assets/{index-BzQQnlIk.js → index-CMONMbrw.js} +1 -1
  47. package/dist/assets/{index-CrltF-5r.js → index-CZiE3NoT.js} +1 -1
  48. package/dist/assets/{index-CQ7rf4K7.js → index-Cu3fKNX9.js} +1 -1
  49. package/dist/assets/{index-DmunXcbP.js → index-DF-djpVa.js} +1 -1
  50. package/dist/assets/{index-Bj_MIGqG.js → index-DFyhjHfe.js} +1 -1
  51. package/dist/assets/{index-DWJwqJmD.js → index-DiR0_rGu.js} +1 -1
  52. package/dist/assets/{index-BlHS2xQv.js → index-Dul-r1kp.js} +1 -1
  53. package/dist/assets/{index-CswmrBaY.js → index-HRU8TIdF.js} +1 -1
  54. package/dist/assets/{index-B3l0i9L2.js → index-X5WGR23V.js} +1 -1
  55. package/dist/assets/{index-Cni7Zj6s.js → index-dg4pAq16.js} +1 -1
  56. package/dist/assets/{index-BA1eBtgJ.js → index-gfSljJAp.js} +1 -1
  57. package/dist/assets/{index-CJRYE40I.js → index-i32tovKe.js} +1 -1
  58. package/dist/assets/{index-DApZsb--.js → index-ihd_9evC.js} +1 -1
  59. package/dist/assets/{index-BMCpi_hV.js → index-o_vV_07U.js} +1 -1
  60. package/dist/assets/infoDiagram-6WOFNB3A-DR1XmZZV.js +2 -0
  61. package/dist/assets/{journeyDiagram-FFXJYRFH-BEiF2rN9.js → journeyDiagram-FFXJYRFH-CKkkxboW.js} +1 -1
  62. package/dist/assets/{kanban-definition-KOZQBZVT-BZZVRqX0.js → kanban-definition-KOZQBZVT-DUp6YZ2m.js} +1 -1
  63. package/dist/assets/{layout-2hUFrdoK.js → layout-C_d3U5fn.js} +1 -1
  64. package/dist/assets/{linear-DM7ygpKu.js → linear-tnIZoYGG.js} +1 -1
  65. package/dist/assets/{links-BJFGdYRd.js → links-drtI4MsD.js} +1 -1
  66. package/dist/assets/{mermaid-CxGPSzDz.js → mermaid-C-kAZ7xK.js} +4 -4
  67. package/dist/assets/{min-DS87IVOa.js → min-BzO2kNxh.js} +1 -1
  68. package/dist/assets/{mindmap-definition-LNHGMQRG-C7yfXmRD.js → mindmap-definition-LNHGMQRG-B5cs3a-r.js} +1 -1
  69. package/dist/assets/{number-overlay-editor-qOf48nx-.js → number-overlay-editor-DaQPfvkb.js} +1 -1
  70. package/dist/assets/{pieDiagram-DBDJKBY4-CRluUHH0.js → pieDiagram-DBDJKBY4-DThp1S_F.js} +1 -1
  71. package/dist/assets/{quadrantDiagram-YPSRARAO-FZO2_Kyx.js → quadrantDiagram-YPSRARAO-CxCwJH_I.js} +1 -1
  72. package/dist/assets/{react-plotly-BV6Vu1ZC.js → react-plotly-B_e_NQp8.js} +1 -1
  73. package/dist/assets/{requirementDiagram-EGVEC5DT-CFEV56bq.js → requirementDiagram-EGVEC5DT-BCEd2wPJ.js} +1 -1
  74. package/dist/assets/{run-page-BiiAHZuC.js → run-page-DyoAk6aS.js} +1 -1
  75. package/dist/assets/{sankeyDiagram-HRAUVNP4-cf6xzlF-.js → sankeyDiagram-HRAUVNP4-BM6Jq2b5.js} +1 -1
  76. package/dist/assets/{sequenceDiagram-WFGC7UMF-CEIC5fba.js → sequenceDiagram-WFGC7UMF-XfD1QkpT.js} +1 -1
  77. package/dist/assets/{slides-component-BXl359nW.js → slides-component-D9Ro2B3g.js} +1 -1
  78. package/dist/assets/{sortBy-Dv7jjKnm.js → sortBy-B5hrNzHC.js} +1 -1
  79. package/dist/assets/{stateDiagram-UUKSUZ4H-DLvG9b91.js → stateDiagram-UUKSUZ4H-uexI_Acp.js} +1 -1
  80. package/dist/assets/stateDiagram-v2-EYPG3UTE-B1T0IMIC.js +1 -0
  81. package/dist/assets/{storage-B47VVzw1.js → storage-Ccm4v9sV.js} +3 -3
  82. package/dist/assets/{terminal-2p_4lGQJ.js → terminal-C27qibMb.js} +1 -1
  83. package/dist/assets/{time-OI-T4MiC.js → time-D8jZTAsX.js} +1 -1
  84. package/dist/assets/{timeline-definition-3HZDQTIS-Da1Ro9aI.js → timeline-definition-3HZDQTIS-DEU2UJxG.js} +1 -1
  85. package/dist/assets/{tracing-CRxB69vv.js → tracing-bjGGc0Pk.js} +2 -2
  86. package/dist/assets/{trash-DM7Eop-E.js → trash-DiDrLeyc.js} +1 -1
  87. package/dist/assets/{treemap-75Q7IDZK-DaZTYidc.js → treemap-75Q7IDZK-CV57Xm0O.js} +1 -1
  88. package/dist/assets/{vega-component-D1rpMJYI.js → vega-component-CockrEy4.js} +1 -1
  89. package/dist/assets/{xychartDiagram-FDP5SA34-COsO2s8u.js → xychartDiagram-FDP5SA34-BhvMyhdb.js} +1 -1
  90. package/dist/index.html +2 -2
  91. package/package.json +2 -2
  92. package/src/__tests__/chat-utils.test.ts +50 -36
  93. package/src/components/chat/chat-panel.tsx +91 -68
  94. package/src/components/chat/markdown-renderer.tsx +29 -16
  95. package/src/core/ai/chat-utils.ts +13 -16
  96. package/src/core/ai/state.ts +30 -10
  97. package/src/utils/__tests__/storage.test.ts +144 -0
  98. package/src/utils/storage.ts +38 -0
  99. package/dist/assets/_baseMap-C6LNFWWn.js +0 -1
  100. package/dist/assets/channel-B3CCoyYy.js +0 -1
  101. package/dist/assets/classDiagram-3BZAVTQC-CLXskjCP.js +0 -1
  102. package/dist/assets/classDiagram-v2-QTMF73CY-CLXskjCP.js +0 -1
  103. package/dist/assets/clone-BCc-guNO.js +0 -1
  104. package/dist/assets/index-BRPMSgmu.css +0 -1
  105. package/dist/assets/infoDiagram-6WOFNB3A-BvitCnlF.js +0 -2
  106. package/dist/assets/stateDiagram-v2-EYPG3UTE-Z_xESMay.js +0 -1
@@ -1,14 +1,26 @@
1
1
  /* Copyright 2024 Marimo. All rights reserved. */
2
2
 
3
3
  import { describe, expect, it } from "vitest";
4
+ import { Maps } from "@/utils/maps";
4
5
  import { addMessageToChat } from "../core/ai/chat-utils";
5
- import type { ChatState } from "../core/ai/state";
6
+ import type { Chat, ChatId, ChatState } from "../core/ai/state";
7
+
8
+ const CHAT_1 = "chat-1" as ChatId;
9
+ const CHAT_2 = "chat-2" as ChatId;
10
+
11
+ function first(map: Map<ChatId, Chat>) {
12
+ return [...map.values()][0];
13
+ }
14
+
15
+ function asMap(list: Iterable<Chat>) {
16
+ return Maps.keyBy(list, (c) => c.id);
17
+ }
6
18
 
7
19
  describe("addMessageToChat", () => {
8
20
  const mockChatState: ChatState = {
9
- chats: [
21
+ chats: asMap([
10
22
  {
11
- id: "chat-1",
23
+ id: CHAT_1,
12
24
  title: "Test Chat 1",
13
25
  messages: [
14
26
  {
@@ -28,7 +40,7 @@ describe("addMessageToChat", () => {
28
40
  updatedAt: 2000,
29
41
  },
30
42
  {
31
- id: "chat-2",
43
+ id: CHAT_2,
32
44
  title: "Test Chat 2",
33
45
  messages: [
34
46
  {
@@ -41,21 +53,21 @@ describe("addMessageToChat", () => {
41
53
  createdAt: 3000,
42
54
  updatedAt: 3000,
43
55
  },
44
- ],
45
- activeChatId: "chat-1",
56
+ ]),
57
+ activeChatId: CHAT_1,
46
58
  };
47
59
 
48
60
  it("should add a new message to an existing chat", () => {
49
61
  const result = addMessageToChat(
50
62
  mockChatState,
51
- "chat-1",
63
+ CHAT_1,
52
64
  "msg-4",
53
65
  "user",
54
66
  "New message",
55
67
  );
56
68
 
57
69
  expect(result.chats).toHaveLength(2);
58
- const updatedChat = result.chats.find((chat) => chat.id === "chat-1");
70
+ const updatedChat = result.chats.get(CHAT_1);
59
71
  expect(updatedChat?.messages).toHaveLength(3);
60
72
  expect(updatedChat?.messages[2]).toEqual({
61
73
  id: "msg-4",
@@ -64,21 +76,21 @@ describe("addMessageToChat", () => {
64
76
  timestamp: expect.any(Number),
65
77
  });
66
78
  expect(updatedChat?.updatedAt).toBeGreaterThan(
67
- mockChatState.chats[0].updatedAt,
79
+ first(mockChatState.chats).updatedAt,
68
80
  );
69
81
  });
70
82
 
71
83
  it("should update an existing message", () => {
72
84
  const result = addMessageToChat(
73
85
  mockChatState,
74
- "chat-1",
86
+ CHAT_1,
75
87
  "msg-1",
76
88
  "user",
77
89
  "Updated content",
78
90
  );
79
91
 
80
92
  expect(result.chats).toHaveLength(2);
81
- const updatedChat = result.chats.find((chat) => chat.id === "chat-1");
93
+ const updatedChat = result.chats.get(CHAT_1);
82
94
  expect(updatedChat?.messages).toHaveLength(2);
83
95
  expect(updatedChat?.messages[0]).toEqual({
84
96
  id: "msg-1",
@@ -87,7 +99,7 @@ describe("addMessageToChat", () => {
87
99
  timestamp: 1000,
88
100
  });
89
101
  expect(updatedChat?.updatedAt).toBeGreaterThan(
90
- mockChatState.chats[0].updatedAt,
102
+ first(mockChatState.chats).updatedAt,
91
103
  );
92
104
  });
93
105
 
@@ -95,48 +107,49 @@ describe("addMessageToChat", () => {
95
107
  const parts = [{ type: "text" as const, text: "Part content" }];
96
108
  const result = addMessageToChat(
97
109
  mockChatState,
98
- "chat-1",
110
+ CHAT_1,
99
111
  "msg-5",
100
112
  "assistant",
101
113
  "Message with parts",
102
114
  parts,
103
115
  );
104
116
 
105
- const updatedChat = result.chats.find((chat) => chat.id === "chat-1");
117
+ const updatedChat = result.chats.get(CHAT_1);
106
118
  expect(updatedChat?.messages[2].parts).toEqual(parts);
107
119
  });
108
120
 
109
121
  it("should update message parts", () => {
110
122
  const originalParts = [{ type: "text" as const, text: "Original" }];
111
123
  const updatedParts = [{ type: "text" as const, text: "Updated" }];
124
+ const chats = [...mockChatState.chats.values()];
112
125
 
113
126
  const stateWithParts: ChatState = {
114
127
  ...mockChatState,
115
- chats: [
128
+ chats: asMap([
116
129
  {
117
- ...mockChatState.chats[0],
130
+ ...chats[0],
118
131
  messages: [
119
132
  {
120
- ...mockChatState.chats[0].messages[0],
133
+ ...chats[0].messages[0],
121
134
  parts: originalParts,
122
135
  },
123
- mockChatState.chats[0].messages[1],
136
+ chats[0].messages[1],
124
137
  ],
125
138
  },
126
- mockChatState.chats[1],
127
- ],
139
+ chats[1],
140
+ ]),
128
141
  };
129
142
 
130
143
  const result = addMessageToChat(
131
144
  stateWithParts,
132
- "chat-1",
145
+ CHAT_1,
133
146
  "msg-1",
134
147
  "user",
135
148
  "Updated content",
136
149
  updatedParts,
137
150
  );
138
151
 
139
- const updatedChat = result.chats.find((chat) => chat.id === "chat-1");
152
+ const updatedChat = result.chats.get(CHAT_1);
140
153
  expect(updatedChat?.messages[0].parts).toEqual(updatedParts);
141
154
  });
142
155
 
@@ -155,7 +168,7 @@ describe("addMessageToChat", () => {
155
168
  it("should return unchanged state when chatId does not exist", () => {
156
169
  const result = addMessageToChat(
157
170
  mockChatState,
158
- "non-existent-chat",
171
+ "non-existent-chat" as ChatId,
159
172
  "msg-4",
160
173
  "user",
161
174
  "New message",
@@ -167,54 +180,55 @@ describe("addMessageToChat", () => {
167
180
  it("should not modify other chats when updating a specific chat", () => {
168
181
  const result = addMessageToChat(
169
182
  mockChatState,
170
- "chat-1",
183
+ CHAT_1,
171
184
  "msg-4",
172
185
  "user",
173
186
  "New message",
174
187
  );
175
188
 
176
- const unchangedChat = result.chats.find((chat) => chat.id === "chat-2");
177
- expect(unchangedChat).toEqual(mockChatState.chats[1]);
189
+ const unchangedChat = result.chats.get(CHAT_2);
190
+ expect(unchangedChat).toEqual([...mockChatState.chats.values()][1]);
178
191
  });
179
192
 
180
193
  it("should preserve message order when adding new messages", () => {
181
194
  const result = addMessageToChat(
182
195
  mockChatState,
183
- "chat-1",
196
+ CHAT_1,
184
197
  "msg-4",
185
198
  "user",
186
199
  "New message",
187
200
  );
188
201
 
189
- const updatedChat = result.chats.find((chat) => chat.id === "chat-1");
202
+ const updatedChat = result.chats.get(CHAT_1);
190
203
  expect(updatedChat?.messages[0].id).toBe("msg-1");
191
204
  expect(updatedChat?.messages[1].id).toBe("msg-2");
192
205
  expect(updatedChat?.messages[2].id).toBe("msg-4");
193
206
  });
194
207
 
195
208
  it("should handle empty chat messages array", () => {
209
+ const chatId = "empty-chat" as ChatId;
196
210
  const emptyChatState: ChatState = {
197
- chats: [
211
+ chats: asMap([
198
212
  {
199
- id: "empty-chat",
213
+ id: chatId,
200
214
  title: "Empty Chat",
201
215
  messages: [],
202
216
  createdAt: 1000,
203
217
  updatedAt: 1000,
204
218
  },
205
- ],
206
- activeChatId: "empty-chat",
219
+ ]),
220
+ activeChatId: chatId,
207
221
  };
208
222
 
209
223
  const result = addMessageToChat(
210
224
  emptyChatState,
211
- "empty-chat",
225
+ chatId,
212
226
  "msg-1",
213
227
  "user",
214
228
  "First message",
215
229
  );
216
230
 
217
- const updatedChat = result.chats.find((chat) => chat.id === "empty-chat");
231
+ const updatedChat = result.chats.get(chatId);
218
232
  expect(updatedChat?.messages).toHaveLength(1);
219
233
  expect(updatedChat?.messages[0].content).toBe("First message");
220
234
  });
@@ -222,13 +236,13 @@ describe("addMessageToChat", () => {
222
236
  it("should handle different message roles", () => {
223
237
  const result = addMessageToChat(
224
238
  mockChatState,
225
- "chat-1",
239
+ CHAT_1,
226
240
  "msg-4",
227
241
  "assistant",
228
242
  "Assistant response",
229
243
  );
230
244
 
231
- const updatedChat = result.chats.find((chat) => chat.id === "chat-1");
245
+ const updatedChat = result.chats.get(CHAT_1);
232
246
  expect(updatedChat?.messages[2].role).toBe("assistant");
233
247
  });
234
248
  });
@@ -18,9 +18,11 @@ import {
18
18
  memo,
19
19
  type SetStateAction,
20
20
  useEffect,
21
+ useMemo,
21
22
  useRef,
22
23
  useState,
23
24
  } from "react";
25
+ import useEvent from "react-use-event-hook";
24
26
  import { Button } from "@/components/ui/button";
25
27
  import {
26
28
  Popover,
@@ -41,6 +43,7 @@ import { useModelChange } from "@/core/ai/config";
41
43
  import {
42
44
  activeChatAtom,
43
45
  type Chat,
46
+ type ChatId,
44
47
  type ChatState,
45
48
  chatStateAtom,
46
49
  } from "@/core/ai/state";
@@ -68,8 +71,8 @@ import { ToolCallAccordion } from "./tool-call-accordion";
68
71
 
69
72
  interface ChatHeaderProps {
70
73
  onNewChat: () => void;
71
- activeChatId: string | undefined;
72
- setActiveChat: (id: string | null) => void;
74
+ activeChatId: ChatId | undefined;
75
+ setActiveChat: (id: ChatId | null) => void;
73
76
  chats: Chat[];
74
77
  }
75
78
 
@@ -151,11 +154,11 @@ interface ChatMessageProps {
151
154
  setChatState: Dispatch<SetStateAction<ChatState>>;
152
155
  chatState: ChatState;
153
156
  isStreamingReasoning: boolean;
154
- totalMessages: number;
157
+ isLast: boolean;
155
158
  }
156
159
 
157
160
  const ChatMessage: React.FC<ChatMessageProps> = memo(
158
- ({ message, index, onEdit, isStreamingReasoning, totalMessages }) => (
161
+ ({ message, index, onEdit, isStreamingReasoning, isLast }) => (
159
162
  <div
160
163
  className={cn(
161
164
  "flex group relative",
@@ -199,7 +202,7 @@ const ChatMessage: React.FC<ChatMessageProps> = memo(
199
202
  key={i}
200
203
  index={i}
201
204
  isStreaming={
202
- index === totalMessages - 1 &&
205
+ isLast &&
203
206
  isStreamingReasoning &&
204
207
  // If there are multiple reasoning parts, only show the last one
205
208
  i === (message.parts?.length || 0) - 1
@@ -235,7 +238,7 @@ const ChatMessage: React.FC<ChatMessageProps> = memo(
235
238
  ChatMessage.displayName = "ChatMessage";
236
239
 
237
240
  interface ChatInputFooterProps {
238
- input: string;
241
+ isEmpty: boolean;
239
242
  onSendClick: () => void;
240
243
  isLoading: boolean;
241
244
  onStop: () => void;
@@ -244,7 +247,7 @@ interface ChatInputFooterProps {
244
247
  const DEFAULT_MODE = "manual";
245
248
 
246
249
  const ChatInputFooter: React.FC<ChatInputFooterProps> = memo(
247
- ({ input, onSendClick, isLoading, onStop }) => {
250
+ ({ isEmpty, onSendClick, isLoading, onStop }) => {
248
251
  const ai = useAtomValue(aiAtom);
249
252
  const currentMode = ai?.mode || DEFAULT_MODE;
250
253
  const currentModel = ai?.models?.chat_model || DEFAULT_AI_MODEL;
@@ -308,7 +311,7 @@ const ChatInputFooter: React.FC<ChatInputFooterProps> = memo(
308
311
  size="sm"
309
312
  className="h-6 w-6 p-0 hover:bg-muted/30"
310
313
  onClick={isLoading ? onStop : onSendClick}
311
- disabled={isLoading ? false : !input.trim()}
314
+ disabled={isLoading ? false : isEmpty}
312
315
  >
313
316
  {isLoading ? (
314
317
  <SquareIcon className="h-3 w-3 fill-current" />
@@ -334,11 +337,11 @@ interface ChatInputProps {
334
337
 
335
338
  const ChatInput: React.FC<ChatInputProps> = memo(
336
339
  ({ input, setInput, onSubmit, inputRef, isLoading, onStop }) => {
337
- const handleSendClick = () => {
340
+ const handleSendClick = useEvent(() => {
338
341
  if (input.trim()) {
339
342
  onSubmit(undefined, input);
340
343
  }
341
- };
344
+ });
342
345
 
343
346
  return (
344
347
  <div className="border-t relative shrink-0 min-h-[80px] flex flex-col">
@@ -352,7 +355,7 @@ const ChatInput: React.FC<ChatInputProps> = memo(
352
355
  />
353
356
  </div>
354
357
  <ChatInputFooter
355
- input={input}
358
+ isEmpty={!input.trim()}
356
359
  onSendClick={handleSendClick}
357
360
  isLoading={isLoading}
358
361
  onStop={onStop}
@@ -463,8 +466,9 @@ const ChatPanelBody = () => {
463
466
  const isLoading = status === "submitted" || status === "streaming";
464
467
 
465
468
  // Sync user messages from useChat to storage when they become available
469
+ // Only when we are done loading, for performance.
466
470
  useEffect(() => {
467
- if (!chatState.activeChatId || messages.length === 0) {
471
+ if (!chatState.activeChatId || messages.length === 0 || isLoading) {
468
472
  return;
469
473
  }
470
474
 
@@ -474,9 +478,7 @@ const ChatPanelBody = () => {
474
478
  return;
475
479
  }
476
480
 
477
- const currentChat = chatState.chats.find(
478
- (c) => c.id === chatState.activeChatId,
479
- );
481
+ const currentChat = chatState.chats.get(chatState.activeChatId);
480
482
  if (!currentChat) {
481
483
  return;
482
484
  }
@@ -505,31 +507,13 @@ const ChatPanelBody = () => {
505
507
  return result;
506
508
  });
507
509
  }
508
- }, [messages, chatState.activeChatId, chatState.chats, setChatState]);
509
-
510
- const isLastMessageReasoning = (messages: Message[]): boolean => {
511
- if (messages.length === 0) {
512
- return false;
513
- }
514
-
515
- const lastMessage = messages.at(-1);
516
- if (!lastMessage) {
517
- return false;
518
- }
519
-
520
- if (lastMessage.role !== "assistant" || !lastMessage.parts) {
521
- return false;
522
- }
523
-
524
- const parts = lastMessage.parts;
525
- if (parts.length === 0) {
526
- return false;
527
- }
528
-
529
- // Check if the last part is reasoning
530
- const lastPart = parts[parts.length - 1];
531
- return lastPart.type === "reasoning";
532
- };
510
+ }, [
511
+ messages,
512
+ chatState.activeChatId,
513
+ chatState.chats,
514
+ setChatState,
515
+ isLoading,
516
+ ]);
533
517
 
534
518
  // Check if we're currently streaming reasoning in the latest message
535
519
  const isStreamingReasoning =
@@ -550,7 +534,7 @@ const ChatPanelBody = () => {
550
534
  const createNewThread = (initialMessage: string) => {
551
535
  const CURRENT_TIME = Date.now();
552
536
  const newChat: Chat = {
553
- id: generateUUID(),
537
+ id: generateUUID() as ChatId,
554
538
  title:
555
539
  initialMessage.length > 50
556
540
  ? `${initialMessage.slice(0, 50)}...`
@@ -562,53 +546,59 @@ const ChatPanelBody = () => {
562
546
 
563
547
  // Create new chat and set as active
564
548
  setChatState((prev) => {
549
+ const newChats = new Map(prev.chats);
550
+ newChats.set(newChat.id, newChat);
565
551
  const newState = {
566
552
  ...prev,
567
- chats: [...prev.chats, newChat],
553
+ chats: newChats,
568
554
  activeChatId: newChat.id,
569
555
  };
570
556
  return newState;
571
557
  });
572
558
 
573
559
  // Trigger AI conversation with append
574
- const MESSAGE_ID = generateUUID();
575
560
  append({
576
- id: MESSAGE_ID,
561
+ id: generateUUID(),
577
562
  role: "user",
578
563
  content: initialMessage,
579
564
  });
580
565
  setInput("");
581
566
  };
582
567
 
583
- const handleNewChat = () => {
568
+ const handleNewChat = useEvent(() => {
584
569
  setActiveChat(null);
585
570
  setInput("");
586
571
  setNewThreadInput("");
587
- };
572
+ });
588
573
 
589
- const handleMessageEdit = (index: number, newValue: string) => {
574
+ const handleMessageEdit = useEvent((index: number, newValue: string) => {
590
575
  // Truncate both useChat and storage
591
576
  setMessages((messages) => messages.slice(0, index));
592
- if (chatState.activeChatId) {
593
- setChatState((prev) => ({
594
- ...prev,
595
- chats: prev.chats.map((chat) =>
596
- chat.id === chatState.activeChatId
597
- ? {
598
- ...chat,
599
- messages: chat.messages.slice(0, index),
600
- updatedAt: Date.now(),
601
- }
602
- : chat,
603
- ),
604
- }));
577
+ const activeChatId = chatState.activeChatId;
578
+ if (activeChatId) {
579
+ setChatState((prev) => {
580
+ const nextChats = new Map(prev.chats);
581
+ const activeChat = chatState.chats.get(activeChatId);
582
+ if (activeChat) {
583
+ nextChats.set(activeChat.id, {
584
+ ...activeChat,
585
+ messages: activeChat.messages.slice(0, index),
586
+ updatedAt: Date.now(),
587
+ });
588
+ }
589
+
590
+ return {
591
+ ...prev,
592
+ chats: nextChats,
593
+ };
594
+ });
605
595
  }
606
596
 
607
597
  append({
608
598
  role: "user",
609
599
  content: newValue,
610
600
  });
611
- };
601
+ });
612
602
 
613
603
  const handleChatInputSubmit = (
614
604
  e: KeyboardEvent | undefined,
@@ -624,12 +614,21 @@ const ChatPanelBody = () => {
624
614
  reload();
625
615
  };
626
616
 
627
- const handleNewThreadSubmit = () => {
628
- newThreadInput.trim() && createNewThread(newThreadInput.trim());
629
- };
617
+ const handleNewThreadSubmit = useEvent(() => {
618
+ if (!newThreadInput.trim()) {
619
+ return;
620
+ }
621
+ createNewThread(newThreadInput.trim());
622
+ });
630
623
 
631
624
  const handleOnCloseThread = () => newThreadInputRef.current?.editor?.blur();
632
625
 
626
+ const sortedChats = useMemo(() => {
627
+ return [...chatState.chats.values()].sort(
628
+ (a, b) => b.updatedAt - a.updatedAt,
629
+ );
630
+ }, [chatState.chats]);
631
+
633
632
  return (
634
633
  <div className="flex flex-col h-[calc(100%-53px)]">
635
634
  <TooltipProvider>
@@ -637,7 +636,7 @@ const ChatPanelBody = () => {
637
636
  onNewChat={handleNewChat}
638
637
  activeChatId={activeChat?.id}
639
638
  setActiveChat={setActiveChat}
640
- chats={[...chatState.chats].sort((a, b) => b.updatedAt - a.updatedAt)}
639
+ chats={sortedChats}
641
640
  />
642
641
  </TooltipProvider>
643
642
 
@@ -658,7 +657,7 @@ const ChatPanelBody = () => {
658
657
  />
659
658
  </div>
660
659
  <ChatInputFooter
661
- input={newThreadInput}
660
+ isEmpty={!newThreadInput.trim()}
662
661
  onSendClick={handleNewThreadSubmit}
663
662
  isLoading={isLoading}
664
663
  onStop={stop}
@@ -668,14 +667,14 @@ const ChatPanelBody = () => {
668
667
 
669
668
  {messages.map((message, idx) => (
670
669
  <ChatMessage
671
- key={idx}
670
+ key={message.id}
672
671
  message={message}
673
672
  index={idx}
674
673
  onEdit={handleMessageEdit}
675
674
  setChatState={setChatState}
676
675
  chatState={chatState}
677
676
  isStreamingReasoning={isStreamingReasoning}
678
- totalMessages={messages.length}
677
+ isLast={idx === messages.length - 1}
679
678
  />
680
679
  ))}
681
680
 
@@ -718,3 +717,27 @@ const ChatPanelBody = () => {
718
717
  </div>
719
718
  );
720
719
  };
720
+
721
+ function isLastMessageReasoning(messages: Message[]): boolean {
722
+ if (messages.length === 0) {
723
+ return false;
724
+ }
725
+
726
+ const lastMessage = messages.at(-1);
727
+ if (!lastMessage) {
728
+ return false;
729
+ }
730
+
731
+ if (lastMessage.role !== "assistant" || !lastMessage.parts) {
732
+ return false;
733
+ }
734
+
735
+ const parts = lastMessage.parts;
736
+ if (parts.length === 0) {
737
+ return false;
738
+ }
739
+
740
+ // Check if the last part is reasoning
741
+ const lastPart = parts[parts.length - 1];
742
+ return lastPart.type === "reasoning";
743
+ }
@@ -81,19 +81,13 @@ function maybeTransform(
81
81
  };
82
82
  }
83
83
 
84
- const CodeBlock = ({ code, language }: CodeBlockProps) => {
85
- const { theme } = useTheme();
84
+ const InsertCodeBlockButton = ({ code, language }: CodeBlockProps) => {
86
85
  const { createNewCell } = useCellActions();
87
86
  const lastFocusedCellId = useLastFocusedCellId();
88
87
  const autoInstantiate = useAtomValue(autoInstantiateAtom);
89
- const [value, setValue] = useState(code);
90
-
91
- useEffect(() => {
92
- setValue(code);
93
- }, [code]);
94
88
 
95
89
  const handleInsertCode = () => {
96
- const result = maybeTransform(language, value);
90
+ const result = maybeTransform(language, code);
97
91
 
98
92
  if (language === "sql") {
99
93
  maybeAddMarimoImport({
@@ -109,6 +103,22 @@ const CodeBlock = ({ code, language }: CodeBlockProps) => {
109
103
  });
110
104
  };
111
105
 
106
+ return (
107
+ <Button size="xs" variant="outline" onClick={handleInsertCode}>
108
+ Add to Notebook
109
+ <BetweenHorizontalStartIcon className="ml-2 h-4 w-4" />
110
+ </Button>
111
+ );
112
+ };
113
+
114
+ const CodeBlock = ({ code, language }: CodeBlockProps) => {
115
+ const { theme } = useTheme();
116
+ const [value, setValue] = useState(code);
117
+
118
+ useEffect(() => {
119
+ setValue(code);
120
+ }, [code]);
121
+
112
122
  const handleCopyCode = async () => {
113
123
  await copyToClipboard(value);
114
124
  };
@@ -132,10 +142,7 @@ const CodeBlock = ({ code, language }: CodeBlockProps) => {
132
142
  <CopyButton size="xs" variant="outline" onClick={handleCopyCode}>
133
143
  Copy
134
144
  </CopyButton>
135
- <Button size="xs" variant="outline" onClick={handleInsertCode}>
136
- Add to Notebook
137
- <BetweenHorizontalStartIcon className="ml-2 h-4 w-4" />
138
- </Button>
145
+ <InsertCodeBlockButton code={value} language={language} />
139
146
  </div>
140
147
  </div>
141
148
  );
@@ -164,17 +171,21 @@ const CopyButton: React.FC<ButtonProps> = ({ onClick, ...props }) => {
164
171
  };
165
172
 
166
173
  const COMPONENTS: Components = {
167
- code: ({ children, className }) => {
174
+ code: ({ children, className, key }) => {
168
175
  const language = className?.replace("language-", "");
169
176
  if (language && typeof children === "string") {
170
177
  return (
171
- <div>
178
+ <div key={key}>
172
179
  <div className="text-xs text-muted-foreground pl-1">{language}</div>
173
180
  <CodeBlock code={children.trim()} language={language} />
174
181
  </div>
175
182
  );
176
183
  }
177
- return <code className={className}>{children}</code>;
184
+ return (
185
+ <code key={key} className={className}>
186
+ {children}
187
+ </code>
188
+ );
178
189
  },
179
190
  };
180
191
 
@@ -183,12 +194,14 @@ function parseMarkdownIntoBlocks(markdown: string): string[] {
183
194
  return tokens.map((token) => token.raw);
184
195
  }
185
196
 
197
+ const PLUGINS = [remarkGfm];
198
+
186
199
  const MemoizedMarkdownBlock = memo(
187
200
  ({ content }: { content: string }) => {
188
201
  return (
189
202
  <Markdown
190
203
  components={COMPONENTS}
191
- remarkPlugins={[remarkGfm]}
204
+ remarkPlugins={PLUGINS}
192
205
  className="prose dark:prose-invert max-w-none prose-pre:pl-0"
193
206
  >
194
207
  {content}