@marimo-team/frontend 0.25.2-dev114 → 0.25.2-dev116

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 (85) hide show
  1. package/dist/assets/{CellStatus-B4z-D5H1.js → CellStatus-CtjqYbWH.js} +1 -1
  2. package/dist/assets/{ErrorBoundary-R-fjO4Cz.js → ErrorBoundary-F1payLgE.js} +2 -2
  3. package/dist/assets/{JsonOutput-BeLVZDXh.js → JsonOutput-Bhgwb5kV.js} +1 -1
  4. package/dist/assets/{MarimoErrorOutput-B2Ajs_V-.js → MarimoErrorOutput-4jDnOIVr.js} +1 -1
  5. package/dist/assets/{RenderHTML-Chx9oQ4S.js → RenderHTML-B5VPTO14.js} +1 -1
  6. package/dist/assets/{RunButton-DnSopQoo.js → RunButton-BIzGc4XI.js} +1 -1
  7. package/dist/assets/{add-cell-with-ai-9oZmQ0tX.js → add-cell-with-ai-Cmm2fga_.js} +10 -10
  8. package/dist/assets/{add-connection-dialog-C82vMzr5.js → add-connection-dialog-DCpxcHW4.js} +1 -1
  9. package/dist/assets/{agent-panel-8bu-4RMv.js → agent-panel-BsG9PQlJ.js} +5 -5
  10. package/dist/assets/ai-model-dropdown-C4Dg70H_.js +5 -0
  11. package/dist/assets/{api-BJPYk7E_.js → api-BeC_VSGv.js} +1 -1
  12. package/dist/assets/app-config-button-qYCFYvje.js +1 -0
  13. package/dist/assets/{blob-BJi30p8H.js → blob-QvTM0DdL.js} +1 -1
  14. package/dist/assets/{cell-editor-CKwmt_18.js → cell-editor-DdAAUVJx.js} +11 -11
  15. package/dist/assets/{cell-link-BI2FitQ_.js → cell-link-CIHoy1VE.js} +1 -1
  16. package/dist/assets/{cells-D73Y_xmw.js → cells-BWyR7TCJ.js} +1 -1
  17. package/dist/assets/{chat-display-CqK2mMHm.js → chat-display-BzvZ1dG6.js} +1 -1
  18. package/dist/assets/chat-panel-CpLCiU26.js +4 -0
  19. package/dist/assets/{chat-ui-CK-mgi6S.js → chat-ui-t3zRs2SX.js} +1 -1
  20. package/dist/assets/{column-preview-biRDE_Oz.js → column-preview-BJr1QW8R.js} +1 -1
  21. package/dist/assets/command-palette-BQWj0cos.js +1 -0
  22. package/dist/assets/{common-8ezkeamF.js → common-2ZN9KNv-.js} +1 -1
  23. package/dist/assets/{components-FsfAPhUd.js → components-_sJpwVD4.js} +1 -1
  24. package/dist/assets/{datasource-Dniq1d2k.js → datasource-C_fKqG0e.js} +1 -1
  25. package/dist/assets/{dependency-graph-panel-Ci62Tv1x.js → dependency-graph-panel-DSol2xZ3.js} +1 -1
  26. package/dist/assets/{documentation-panel-B-0JfpSn.js → documentation-panel-BcxEbDPv.js} +1 -1
  27. package/dist/assets/{download-CEG5vQri.js → download-B5cBmz55.js} +1 -1
  28. package/dist/assets/{edit-page-VV7jun46.js → edit-page-DMQmQsPo.js} +6 -6
  29. package/dist/assets/{error-entries-Frq0QZL-.js → error-entries-PrZ4UIKa.js} +1 -1
  30. package/dist/assets/{error-panel-BeSB6k3e.js → error-panel-Cuohhvyw.js} +1 -1
  31. package/dist/assets/feedback-button-5oy5W7Kx.js +1 -0
  32. package/dist/assets/{feedback-button-DALE40Cq.js → feedback-button-aEZd8wqo.js} +1 -1
  33. package/dist/assets/{file-explorer-panel-DkhJMce7.js → file-explorer-panel-DMrCYHTB.js} +1 -1
  34. package/dist/assets/{file-name-input-DsLb3kUE.js → file-name-input-CRkt1da3.js} +1 -1
  35. package/dist/assets/{floating-outline-Bivo15Ee.js → floating-outline-BnO_h00w.js} +1 -1
  36. package/dist/assets/{gallery-page-BipSEe8i.js → gallery-page-g3iLOyQO.js} +1 -1
  37. package/dist/assets/{glide-data-editor-DWxXZHhh.js → glide-data-editor-WZMQjVJd.js} +1 -1
  38. package/dist/assets/{home-page-4xyvW9LC.js → home-page-vBOIWWG_.js} +1 -1
  39. package/dist/assets/{hooks-B7y2bbmR.js → hooks-DlgJEnzq.js} +1 -1
  40. package/dist/assets/{index-DLi7reLY.js → index-DTa3czvL.js} +4 -4
  41. package/dist/assets/{layout-B5DZz9ah.js → layout-88dS_AsT.js} +3 -3
  42. package/dist/assets/{logs-panel-CswvaDqE.js → logs-panel-Ct5o61k4.js} +1 -1
  43. package/dist/assets/{markdown-renderer-CErbddaa.js → markdown-renderer-bBVFObv0.js} +1 -1
  44. package/dist/assets/{name-cell-input-DXf9tZ25.js → name-cell-input-RN-BHucn.js} +1 -1
  45. package/dist/assets/{outline-panel-BPlLwpVm.js → outline-panel-B_GILrNI.js} +1 -1
  46. package/dist/assets/{packages-panel-CoIvCGdp.js → packages-panel-r_YSs3uG.js} +1 -1
  47. package/dist/assets/{panel-accordion-state-B50Y_3hb.js → panel-accordion-state-BF3wKXuD.js} +1 -1
  48. package/dist/assets/{panels-j8cWK9B8.js → panels-DNYjDBgD.js} +1 -1
  49. package/dist/assets/{process-output-Bfoxbqpj.js → process-output-C_JmjPkt.js} +1 -1
  50. package/dist/assets/{readonly-code-display-BusLGSPF.js → readonly-code-display-Bsv-J4Xx.js} +1 -1
  51. package/dist/assets/{readonly-python-code-CTbOf95g.js → readonly-python-code-bOYKcbg4.js} +1 -1
  52. package/dist/assets/{reveal-component-D3wlU8v1.js → reveal-component-CLJ6dooc.js} +1 -1
  53. package/dist/assets/{run-page-B03JZs0U.js → run-page-FdfJRz8z.js} +1 -1
  54. package/dist/assets/{runtime-details-state-Ddpsoriz.js → runtime-details-state-ZkKdmmAJ.js} +1 -1
  55. package/dist/assets/{scratchpad-panel-B9RdvhAM.js → scratchpad-panel-D3bf52na.js} +1 -1
  56. package/dist/assets/{session-panel-D3E0Ag30.js → session-panel-DJ2aIlDv.js} +1 -1
  57. package/dist/assets/{snippets-panel-Bby1zIJE.js → snippets-panel-C-lvTkYT.js} +1 -1
  58. package/dist/assets/{state-ENE4B_JH.js → state-C1RQhToR.js} +1 -1
  59. package/dist/assets/{state-zdyODrb0.js → state-DMZ8VJcq.js} +1 -1
  60. package/dist/assets/{tracing-De9k9D6u.js → tracing-f3Wv0MqG.js} +1 -1
  61. package/dist/assets/{tracing-panel-DvISFiEI.js → tracing-panel-BDSbtHAO.js} +2 -2
  62. package/dist/assets/{tree-actions-D5HC2yfb.js → tree-actions-PNciswRG.js} +1 -1
  63. package/dist/assets/{useAddCell-DAJ9Wd-E.js → useAddCell-mcL2PJTv.js} +1 -1
  64. package/dist/assets/{useCellActionButton-CWhycKft.js → useCellActionButton-CYjo2N8X.js} +1 -1
  65. package/dist/assets/{useDeleteCell-8ByYNceO.js → useDeleteCell-hqk8CkzR.js} +1 -1
  66. package/dist/assets/{useDependencyPanelTab-Dz37hZtr.js → useDependencyPanelTab-rJGeCoZQ.js} +1 -1
  67. package/dist/assets/useNotebookActions-BEpiNBZu.js +1 -0
  68. package/dist/assets/{useRunCells-BVoQp_ne.js → useRunCells-DIY1IVXx.js} +1 -1
  69. package/dist/assets/{useSplitCell-Ba_Zm0ID.js → useSplitCell-BGcgAf0Z.js} +1 -1
  70. package/dist/index.html +20 -20
  71. package/package.json +3 -3
  72. package/src/core/codemirror/copilot/__tests__/completion-integration.test.ts +447 -0
  73. package/src/core/codemirror/copilot/__tests__/inline-completion.test.ts +395 -0
  74. package/src/core/codemirror/copilot/__tests__/language-server.test.ts +511 -2
  75. package/src/core/codemirror/copilot/__tests__/rpc-cancellation.test.ts +201 -0
  76. package/src/core/codemirror/copilot/extension.ts +22 -11
  77. package/src/core/codemirror/copilot/language-server.ts +109 -60
  78. package/src/core/network/__tests__/api.test.ts +114 -1
  79. package/src/core/network/api.ts +3 -2
  80. package/dist/assets/ai-model-dropdown-BKQARUae.js +0 -5
  81. package/dist/assets/app-config-button-DKoDBgwu.js +0 -1
  82. package/dist/assets/chat-panel-BgJcxa4q.js +0 -4
  83. package/dist/assets/command-palette-CHUS5_VG.js +0 -1
  84. package/dist/assets/feedback-button-CrqeO2wx.js +0 -1
  85. package/dist/assets/useNotebookActions-CYbHF8MU.js +0 -1
@@ -0,0 +1,201 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import {
4
+ JSONRPCClient,
5
+ type JSONRPCMessage,
6
+ type Transport,
7
+ } from "@marimo-team/codemirror-languageserver";
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+
10
+ describe("LSP request cancellation", () => {
11
+ let client: JSONRPCClient;
12
+ let emit: (message: JSONRPCMessage) => void;
13
+ let disconnect: (error: Error) => void;
14
+ let connected: ReturnType<typeof Promise.withResolvers<undefined>>;
15
+ let transport: Transport & {
16
+ send: ReturnType<typeof vi.fn<(message: JSONRPCMessage) => void>>;
17
+ };
18
+
19
+ beforeEach(() => {
20
+ vi.useFakeTimers();
21
+ connected = Promise.withResolvers<undefined>();
22
+ transport = {
23
+ connect: () => connected.promise,
24
+ close: vi.fn(),
25
+ send: vi.fn<(message: JSONRPCMessage) => void>(),
26
+ onMessage: (handler) => {
27
+ emit = handler;
28
+ return vi.fn();
29
+ },
30
+ onClose: (handler) => {
31
+ disconnect = handler;
32
+ return vi.fn();
33
+ },
34
+ };
35
+ client = new JSONRPCClient(transport);
36
+ });
37
+
38
+ afterEach(() => {
39
+ client.close();
40
+ vi.useRealTimers();
41
+ });
42
+
43
+ async function connect() {
44
+ connected.resolve(undefined);
45
+ await vi.advanceTimersByTimeAsync(0);
46
+ }
47
+
48
+ it("does not send an already-aborted request", async () => {
49
+ await connect();
50
+ const controller = new AbortController();
51
+ controller.abort();
52
+ await expect(
53
+ client.request("complete", {}, 1000, controller.signal),
54
+ ).rejects.toMatchObject({ name: "AbortError" });
55
+ expect(transport.send).not.toHaveBeenCalled();
56
+ expect(vi.getTimerCount()).toBe(0);
57
+ });
58
+
59
+ it("cancels while connecting without sending either request or cancellation", async () => {
60
+ const controller = new AbortController();
61
+ const response = client.request("complete", {}, 1000, controller.signal);
62
+ controller.abort();
63
+ await expect(response).rejects.toMatchObject({ name: "AbortError" });
64
+ await connect();
65
+ expect(transport.send).not.toHaveBeenCalled();
66
+ expect(vi.getTimerCount()).toBe(0);
67
+ });
68
+
69
+ it("rejects a failed connection and removes the waiting request's abort listener", async () => {
70
+ const controller = new AbortController();
71
+ const remove = vi.spyOn(controller.signal, "removeEventListener");
72
+ const response = client.request("complete", {}, 1000, controller.signal);
73
+ const settled = response.catch((error: unknown) => error);
74
+ connected.reject(new Error("connection refused"));
75
+ await expect(settled).resolves.toMatchObject({
76
+ message: "connection refused",
77
+ });
78
+ expect(remove).toHaveBeenCalledExactlyOnceWith(
79
+ "abort",
80
+ expect.any(Function),
81
+ );
82
+ controller.abort();
83
+ expect(transport.send).not.toHaveBeenCalled();
84
+ expect(vi.getTimerCount()).toBe(0);
85
+ });
86
+
87
+ it("cancels exactly the matching request, ignores late replies, and leaves others active", async () => {
88
+ await connect();
89
+ const controller = new AbortController();
90
+ const first = client.request(
91
+ "complete",
92
+ { position: 1 },
93
+ 1000,
94
+ controller.signal,
95
+ );
96
+ const second = client.request("complete", { position: 2 }, 1000);
97
+ controller.abort();
98
+ controller.abort();
99
+ await expect(first).rejects.toMatchObject({ name: "AbortError" });
100
+ emit({ jsonrpc: "2.0", id: 0, result: "stale" });
101
+ emit({ jsonrpc: "2.0", id: 1, result: "latest" });
102
+ await expect(second).resolves.toBe("latest");
103
+ expect(transport.send.mock.calls).toEqual([
104
+ [{ jsonrpc: "2.0", id: 0, method: "complete", params: { position: 1 } }],
105
+ [{ jsonrpc: "2.0", id: 1, method: "complete", params: { position: 2 } }],
106
+ [{ jsonrpc: "2.0", method: "$/cancelRequest", params: { id: 0 } }],
107
+ ]);
108
+ expect(vi.getTimerCount()).toBe(0);
109
+ });
110
+
111
+ it.each([
112
+ "success",
113
+ "error",
114
+ "timeout",
115
+ "disconnect",
116
+ "close",
117
+ "send failure",
118
+ ])("removes the abort listener and timer after %s", async (outcome) => {
119
+ await connect();
120
+ const controller = new AbortController();
121
+ const remove = vi.spyOn(controller.signal, "removeEventListener");
122
+ if (outcome === "send failure") {
123
+ transport.send.mockImplementationOnce(() => {
124
+ throw new Error("send failed");
125
+ });
126
+ }
127
+ const response = client.request("complete", {}, 1000, controller.signal);
128
+ // Attach the rejection handler before advancing timers or dropping the connection.
129
+ const settled = response.then(
130
+ (value) => ({ status: "fulfilled", value }),
131
+ (error: unknown) => ({ status: "rejected", error }),
132
+ );
133
+ if (outcome === "success") {
134
+ emit({ jsonrpc: "2.0", id: 0, result: "ok" });
135
+ }
136
+ if (outcome === "error") {
137
+ emit({
138
+ jsonrpc: "2.0",
139
+ id: 0,
140
+ error: { code: -32603, message: "failed" },
141
+ });
142
+ }
143
+ if (outcome === "timeout") {
144
+ await vi.advanceTimersByTimeAsync(1000);
145
+ }
146
+ if (outcome === "disconnect") {
147
+ disconnect(new Error("disconnected"));
148
+ }
149
+ if (outcome === "close") {
150
+ client.close();
151
+ }
152
+ const expectedError = expect.any(Error);
153
+ expect(await settled).toEqual(
154
+ outcome === "success"
155
+ ? { status: "fulfilled", value: "ok" }
156
+ : { status: "rejected", error: expectedError },
157
+ );
158
+ expect(remove).toHaveBeenCalledExactlyOnceWith(
159
+ "abort",
160
+ expect.any(Function),
161
+ );
162
+ expect(vi.getTimerCount()).toBe(0);
163
+ const calls = transport.send.mock.calls.length;
164
+ controller.abort();
165
+ expect(transport.send).toHaveBeenCalledTimes(calls);
166
+ });
167
+
168
+ it("sends exactly one cancellation at the timeout boundary and ignores a late reply", async () => {
169
+ await connect();
170
+ const controller = new AbortController();
171
+ const settled = client
172
+ .request("complete", {}, 1000, controller.signal)
173
+ .catch((error: unknown) => error);
174
+ await vi.advanceTimersByTimeAsync(999);
175
+ expect(transport.send.mock.calls).toEqual([
176
+ [{ jsonrpc: "2.0", id: 0, method: "complete", params: {} }],
177
+ ]);
178
+ await vi.advanceTimersByTimeAsync(1);
179
+ expect(transport.send.mock.calls).toEqual([
180
+ [{ jsonrpc: "2.0", id: 0, method: "complete", params: {} }],
181
+ [{ jsonrpc: "2.0", method: "$/cancelRequest", params: { id: 0 } }],
182
+ ]);
183
+ emit({ jsonrpc: "2.0", id: 0, result: "late" });
184
+ controller.abort();
185
+ await expect(settled).resolves.toMatchObject({ code: -32001 });
186
+ expect(transport.send).toHaveBeenCalledTimes(2);
187
+ expect(vi.getTimerCount()).toBe(0);
188
+ });
189
+
190
+ it("settles cancellation even if sending the cancellation notification fails", async () => {
191
+ await connect();
192
+ const controller = new AbortController();
193
+ const response = client.request("complete", {}, 1000, controller.signal);
194
+ transport.send.mockImplementationOnce(() => {
195
+ throw new Error("connection gone");
196
+ });
197
+ controller.abort();
198
+ await expect(response).rejects.toMatchObject({ name: "AbortError" });
199
+ expect(vi.getTimerCount()).toBe(0);
200
+ });
201
+ });
@@ -39,7 +39,7 @@ const copilotCompartment = new Compartment();
39
39
  const logger = Logger.get("@github/copilot-language-server");
40
40
 
41
41
  const commonInlineCompletionConfig = {
42
- delay: 300, // default is 500ms
42
+ delay: 150,
43
43
  includeKeymap: true,
44
44
  events: {
45
45
  // Only show suggestions when the editor is focused
@@ -79,7 +79,7 @@ export const copilotBundle = (config: CompletionConfig): Extension => {
79
79
  extensions.push(
80
80
  inlineCompletion({
81
81
  ...commonInlineCompletionConfig,
82
- fetchFn: async (state, _abortSignal, view) => {
82
+ fetchFn: async (state, signal, view) => {
83
83
  const currentPosition = state.selection.main.head;
84
84
  if (!isCopilotEnabled()) {
85
85
  return "";
@@ -94,16 +94,22 @@ export const copilotBundle = (config: CompletionConfig): Extension => {
94
94
  return "";
95
95
  }
96
96
 
97
- // wait 20ms so that the view is updated first
98
- await new Promise((resolve) => setTimeout(resolve, 20));
99
-
100
- const currentCode = state.doc.toString();
101
- const allCode = getCodes(currentCode);
97
+ const client = getCopilotClient();
98
+ const allCode = await client.flushDocumentChanges(
99
+ state.doc.toString(),
100
+ );
101
+ if (signal.aborted || state.doc !== view.state.doc) {
102
+ return "";
103
+ }
102
104
  const request = getCopilotRequest(state, allCode);
103
- const response = await getCopilotClient().getCompletion(request);
105
+ const response = await client.getCompletion(request, signal);
104
106
 
105
107
  // If we are at a new position, ignore the response
106
- if (currentPosition !== view.state.selection.main.head) {
108
+ if (
109
+ signal.aborted ||
110
+ state.doc !== view.state.doc ||
111
+ currentPosition !== view.state.selection.main.head
112
+ ) {
107
113
  Logger.debug("ignoring response because of new position");
108
114
  return "";
109
115
  }
@@ -119,7 +125,7 @@ export const copilotBundle = (config: CompletionConfig): Extension => {
119
125
  extensions.push(
120
126
  inlineCompletion({
121
127
  ...commonInlineCompletionConfig,
122
- fetchFn: async (state, _abortSignal, view) => {
128
+ fetchFn: async (state, signal, view) => {
123
129
  const currentPosition = state.selection.main.head;
124
130
 
125
131
  if (state.doc.length === 0) {
@@ -146,10 +152,15 @@ export const copilotBundle = (config: CompletionConfig): Extension => {
146
152
  let res = await API.post<AiInlineCompletionRequest, string>(
147
153
  "/ai/inline_completion",
148
154
  { prefix, suffix, language },
155
+ { signal },
149
156
  );
150
157
 
151
158
  // If we are at a new position, ignore the response
152
- if (currentPosition !== view.state.selection.main.head) {
159
+ if (
160
+ signal.aborted ||
161
+ state.doc !== view.state.doc ||
162
+ currentPosition !== view.state.selection.main.head
163
+ ) {
153
164
  Logger.debug("ignoring response because of new position");
154
165
  return "";
155
166
  }
@@ -1,7 +1,6 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
3
  import { LanguageServerClient } from "@marimo-team/codemirror-languageserver";
4
- import { throttle } from "lodash-es";
5
4
  import type {
6
5
  CompletionItem,
7
6
  CompletionList,
@@ -58,6 +57,7 @@ interface UntypedLanguageServerMethods {
58
57
  method: string,
59
58
  params: unknown,
60
59
  timeout: number,
60
+ signal?: AbortSignal,
61
61
  ) => Promise<unknown>;
62
62
  notify: (method: string, params: unknown) => Promise<void>;
63
63
  }
@@ -113,6 +113,8 @@ function isLogMessageParams(
113
113
  */
114
114
  export class CopilotLanguageServerClient extends LanguageServerClient {
115
115
  private documentVersion = 0;
116
+ private pendingChange: DidChangeTextDocumentParams | undefined;
117
+ private completionController: AbortController | undefined;
116
118
  private openDocumentCount = 0;
117
119
  private lastOpenedDocument: DidOpenTextDocumentParams | undefined;
118
120
  private copilotSettings: Record<string, unknown> = {};
@@ -142,9 +144,11 @@ export class CopilotLanguageServerClient extends LanguageServerClient {
142
144
  */
143
145
  public async reInitialize(): Promise<void> {
144
146
  logger.log("#reInitialize: Re-initializing LSP connection");
147
+ this.completionController?.abort();
145
148
  this.initializePromise = this.initialize();
146
149
  await this.initializePromise;
147
150
  await this.sendConfiguration();
151
+ this.applyPendingChange();
148
152
  if (this.openDocumentCount > 0 && this.lastOpenedDocument) {
149
153
  await this.sendNotification(
150
154
  "textDocument/didOpen",
@@ -168,12 +172,14 @@ export class CopilotLanguageServerClient extends LanguageServerClient {
168
172
  private async _request<Method extends keyof LSPRequestMap>(
169
173
  method: Method,
170
174
  params: LSPRequestMap[Method][0],
175
+ signal?: AbortSignal,
171
176
  ): Promise<LSPRequestMap[Method][1]> {
172
177
  assertHasLanguageServerRpc(this);
173
178
  return (await this.request(
174
179
  method,
175
180
  params,
176
181
  REQUEST_TIMEOUT_MS,
182
+ signal,
177
183
  )) as LSPRequestMap[Method][1];
178
184
  }
179
185
 
@@ -230,6 +236,10 @@ export class CopilotLanguageServerClient extends LanguageServerClient {
230
236
  return;
231
237
  }
232
238
  this.openDocumentCount--;
239
+ if (this.openDocumentCount === 0) {
240
+ this.pendingChange = undefined;
241
+ this.completionController?.abort();
242
+ }
233
243
  return super.textDocumentDidClose(params);
234
244
  }
235
245
 
@@ -281,27 +291,72 @@ export class CopilotLanguageServerClient extends LanguageServerClient {
281
291
  );
282
292
  }
283
293
 
284
- const text = getCodes(change.text);
285
- const version = ++this.documentVersion;
286
- // `reInitialize` replays this as a didOpen, so keep the original languageId
294
+ this.completionController?.abort();
295
+ this.pendingChange = {
296
+ ...params,
297
+ textDocument: VersionedTextDocumentIdentifier.create(
298
+ params.textDocument.uri,
299
+ ++this.documentVersion,
300
+ ),
301
+ };
302
+ }
303
+
304
+ private applyPendingChange(
305
+ assembledText?: string,
306
+ ): DidChangeTextDocumentParams | undefined {
307
+ const pending = this.pendingChange;
308
+ if (!pending) {
309
+ return;
310
+ }
311
+ this.pendingChange = undefined;
312
+ const text = assembledText ?? getCodes(pending.contentChanges[0].text);
287
313
  this.lastOpenedDocument = {
288
314
  textDocument: {
289
- uri: params.textDocument.uri,
315
+ ...pending.textDocument,
290
316
  languageId:
291
317
  this.lastOpenedDocument?.textDocument.languageId ??
292
318
  DEFAULT_LANGUAGE_ID,
293
- version,
294
319
  text,
295
320
  },
296
321
  };
297
- return super.textDocumentDidChange({
298
- ...params,
299
- contentChanges: [{ text: text }],
300
- textDocument: VersionedTextDocumentIdentifier.create(
301
- params.textDocument.uri,
302
- version,
303
- ),
304
- });
322
+ return { ...pending, contentChanges: [{ text }] };
323
+ }
324
+
325
+ /** Assemble and send the latest notebook once, just before requesting a suggestion. */
326
+ public async flushDocumentChanges(currentCode?: string): Promise<string> {
327
+ // Another cell may have changed since the focused editor last typed.
328
+ // The focused cell must be last in the shared document for cursor mapping.
329
+ const assembledText =
330
+ currentCode !== undefined && this.lastOpenedDocument
331
+ ? getCodes(currentCode)
332
+ : undefined;
333
+ if (
334
+ currentCode !== undefined &&
335
+ this.lastOpenedDocument &&
336
+ (this.pendingChange
337
+ ? this.pendingChange.contentChanges[0].text !== currentCode
338
+ : assembledText !== this.lastOpenedDocument.textDocument.text)
339
+ ) {
340
+ this.pendingChange = {
341
+ textDocument: VersionedTextDocumentIdentifier.create(
342
+ this.lastOpenedDocument.textDocument.uri,
343
+ ++this.documentVersion,
344
+ ),
345
+ contentChanges: [{ text: currentCode }],
346
+ };
347
+ }
348
+ const pending = this.pendingChange;
349
+ const change = this.applyPendingChange(assembledText);
350
+ if (change) {
351
+ try {
352
+ await super.textDocumentDidChange(change);
353
+ } catch (error) {
354
+ // Preserve newer edits if the send failed while another change arrived.
355
+ this.pendingChange ??= pending;
356
+ throw error;
357
+ }
358
+ }
359
+ return this.lastOpenedDocument?.textDocument.text ?? "";
305
360
  }
306
361
 
307
362
  public override textDocumentHover(params: HoverParams): Promise<Hover> {
@@ -351,49 +406,32 @@ export class CopilotLanguageServerClient extends LanguageServerClient {
351
406
  }
352
407
  }
353
408
 
354
- private getCompletionInternal = async (
355
- params: InlineCompletionParams,
356
- version: number,
357
- ): Promise<InlineCompletionList | InlineCompletionItem[] | null> => {
358
- return await this._request("textDocument/inlineCompletion", {
359
- ...params,
360
- textDocument: {
361
- ...VersionedTextDocumentIdentifier.create(
362
- params.textDocument.uri,
363
- version,
364
- ),
365
- },
366
- });
367
- };
368
-
369
- // Even though the copilot extension has a debounce,
370
- // there are multiple requests sent at the same time
371
- // when multiple Codemirror instances are mounted at the same time.
372
- // So we throttle it to ignore multiple requests at the same time.
373
- private throttledGetCompletionInternal = throttle(
374
- this.getCompletionInternal,
375
- 200,
376
- );
377
-
378
409
  public async getCompletion(
379
410
  params: InlineCompletionParams,
411
+ signal?: AbortSignal,
380
412
  ): Promise<InlineCompletionList | InlineCompletionItem[] | null> {
381
- if (this.isDisabled()) {
413
+ if (this.isDisabled() || signal?.aborted || this.openDocumentCount === 0) {
382
414
  return null;
383
415
  }
384
416
 
417
+ this.completionController?.abort();
418
+ const controller = new AbortController();
419
+ this.completionController = controller;
420
+ const abort = () => controller.abort();
421
+ signal?.addEventListener("abort", abort, { once: true });
385
422
  const requestVersion = this.documentVersion;
386
-
387
- // If version is 0, it means the document hasn't been opened yet
388
- if (requestVersion === 0) {
389
- return null;
390
- }
391
-
392
- // Start a loading indicator
393
423
  setGitHubCopilotLoadingVersion(requestVersion);
394
- let response: InlineCompletionList | InlineCompletionItem[] | null;
395
424
  try {
396
- response = await this.throttledGetCompletionInternal(
425
+ await this.flushDocumentChanges();
426
+ if (
427
+ controller.signal.aborted ||
428
+ this.isDisabled() ||
429
+ requestVersion === 0
430
+ ) {
431
+ return null;
432
+ }
433
+ const response = await this._request(
434
+ "textDocument/inlineCompletion",
397
435
  {
398
436
  ...params,
399
437
  textDocument: VersionedTextDocumentIdentifier.create(
@@ -401,24 +439,35 @@ export class CopilotLanguageServerClient extends LanguageServerClient {
401
439
  requestVersion,
402
440
  ),
403
441
  },
404
- requestVersion,
442
+ controller.signal,
405
443
  );
444
+ if (
445
+ controller.signal.aborted ||
446
+ this.isDisabled() ||
447
+ requestVersion !== this.documentVersion
448
+ ) {
449
+ return null;
450
+ }
451
+ return response;
406
452
  } catch (error) {
407
- // A suggestion that times out should fail quietly rather than surface as
408
- // an unhandled rejection out of the inline-completion fetcher.
409
- logger.warn("#getCompletion: Failed to fetch completions", error);
453
+ if (!controller.signal.aborted) {
454
+ logger.warn("#getCompletion: Failed to fetch completions", error);
455
+ }
410
456
  return null;
411
457
  } finally {
412
- // Stop the loading indicator (only if the version hasn't changed)
413
- clearGitHubCopilotLoadingVersion(requestVersion);
414
- }
415
-
416
- // If the document version has changed since the request was made, return an empty response
417
- if (requestVersion !== this.documentVersion) {
418
- return null;
458
+ signal?.removeEventListener("abort", abort);
459
+ // A superseded request can have the same document version as its successor.
460
+ if (this.completionController === controller) {
461
+ this.completionController = undefined;
462
+ clearGitHubCopilotLoadingVersion(requestVersion);
463
+ }
419
464
  }
465
+ }
420
466
 
421
- return response ?? null;
467
+ public override close(): void {
468
+ this.pendingChange = undefined;
469
+ this.completionController?.abort();
470
+ super.close();
422
471
  }
423
472
 
424
473
  /**
@@ -1,7 +1,8 @@
1
1
  /* Copyright 2026 Marimo. All rights reserved. */
2
2
 
3
- import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
4
4
  import { HTTPError } from "@/utils/errors";
5
+ import { Logger } from "@/utils/Logger";
5
6
  import { API } from "../api";
6
7
 
7
8
  // Mock fetch globally
@@ -25,6 +26,118 @@ describe("API", () => {
25
26
  baseUrl = "http://localhost:8000";
26
27
  });
27
28
 
29
+ afterEach(() => {
30
+ vi.restoreAllMocks();
31
+ });
32
+
33
+ it.each(["before fetch", "during fetch"])(
34
+ "API.post propagates cancellation %s without logging it",
35
+ async (timing) => {
36
+ const log = vi.spyOn(Logger, "error").mockReturnValue(undefined);
37
+ const controller = new AbortController();
38
+ mockFetch.mockImplementation(
39
+ (_url: string, { signal }: RequestInit) =>
40
+ new Promise<Response>((_resolve, reject) => {
41
+ if (signal?.aborted) {
42
+ reject(signal.reason);
43
+ } else {
44
+ signal?.addEventListener("abort", () => reject(signal.reason), {
45
+ once: true,
46
+ });
47
+ }
48
+ }),
49
+ );
50
+ if (timing === "before fetch") {
51
+ controller.abort();
52
+ }
53
+
54
+ const response = API.post("/foo", {}, { signal: controller.signal });
55
+ controller.abort();
56
+
57
+ await expect(response).rejects.toBe(controller.signal.reason);
58
+ expect(mockFetch).toHaveBeenCalledWith(
59
+ "http://localhost:8000/api/foo",
60
+ expect.objectContaining({ signal: controller.signal }),
61
+ );
62
+ expect(log).not.toHaveBeenCalled();
63
+ },
64
+ );
65
+
66
+ it.each(["application/json", "text/plain"])(
67
+ "API.post propagates an abort while reading %s without logging it",
68
+ async (contentType) => {
69
+ const log = vi.spyOn(Logger, "error").mockReturnValue(undefined);
70
+ const error = new DOMException("Body read aborted", "AbortError");
71
+ const readBody = vi.fn().mockRejectedValue(error);
72
+ mockFetch.mockResolvedValue({
73
+ ok: true,
74
+ headers: new Headers({ "Content-Type": contentType }),
75
+ json: readBody,
76
+ text: readBody,
77
+ });
78
+
79
+ await expect(API.post("/foo", {})).rejects.toBe(error);
80
+ expect(readBody).toHaveBeenCalledExactlyOnceWith();
81
+ expect(log).not.toHaveBeenCalled();
82
+ },
83
+ );
84
+
85
+ it.each([new TypeError("Failed to fetch"), null, "Request failed"])(
86
+ "API.post logs and preserves non-abort fetch failures: %s",
87
+ async (error) => {
88
+ const log = vi.spyOn(Logger, "error").mockReturnValue(undefined);
89
+ const controller = new AbortController();
90
+ controller.abort();
91
+ mockFetch.mockRejectedValue(error);
92
+
93
+ await expect(
94
+ API.post("/foo", {}, { signal: controller.signal }),
95
+ ).rejects.toBe(error);
96
+ expect(log).toHaveBeenCalledExactlyOnceWith(
97
+ "Error requesting http://localhost:8000/api/foo",
98
+ error,
99
+ );
100
+ },
101
+ );
102
+
103
+ it("API.post logs HTTP failures and preserves their status and body", async () => {
104
+ const log = vi.spyOn(Logger, "error").mockReturnValue(undefined);
105
+ const body = { detail: "Service unavailable" };
106
+ mockFetch.mockResolvedValue(
107
+ new Response(JSON.stringify(body), {
108
+ status: 503,
109
+ statusText: "Service Unavailable",
110
+ headers: { "Content-Type": "application/json" },
111
+ }),
112
+ );
113
+
114
+ await expect(API.post("/foo", {})).rejects.toMatchObject({
115
+ name: "HTTPError",
116
+ status: 503,
117
+ message: "Service Unavailable",
118
+ cause: body,
119
+ });
120
+ expect(log).toHaveBeenCalledExactlyOnceWith(
121
+ "Error requesting http://localhost:8000/api/foo",
122
+ expect.any(HTTPError),
123
+ );
124
+ });
125
+
126
+ it("API.post logs malformed JSON responses", async () => {
127
+ const log = vi.spyOn(Logger, "error").mockReturnValue(undefined);
128
+ mockFetch.mockResolvedValue(
129
+ new Response("invalid json", {
130
+ headers: { "Content-Type": "application/json" },
131
+ }),
132
+ );
133
+
134
+ await expect(API.post("/foo", {})).rejects.toBeInstanceOf(SyntaxError);
135
+ expect(log).toHaveBeenCalledExactlyOnceWith(
136
+ "Error requesting http://localhost:8000/api/foo",
137
+ expect.any(SyntaxError),
138
+ );
139
+ });
140
+
28
141
  it("API.post calls fetch with POST and correct URL", async () => {
29
142
  mockFetch.mockResolvedValue({
30
143
  ok: true,
@@ -101,8 +101,9 @@ export const API = {
101
101
  return response.text() as unknown as RESP;
102
102
  })
103
103
  .catch((error) => {
104
- // Catch and rethrow
105
- Logger.error(`Error requesting ${fullUrl}`, error);
104
+ if (error?.name !== "AbortError") {
105
+ Logger.error(`Error requesting ${fullUrl}`, error);
106
+ }
106
107
  throw error;
107
108
  });
108
109
  },