@marimo-team/islands 0.24.3-dev64 → 0.24.3-dev68

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 (32) hide show
  1. package/dist/{ErrorBoundary-A-mTqnGP.js → ErrorBoundary-BIGPDJ8X.js} +1 -1
  2. package/dist/{cells-BSgNCBZ8.js → cells-BLjghRgH.js} +4 -7
  3. package/dist/{chat-ui-CygzVJ9s.js → chat-ui-ZVLuVQRR.js} +4 -4
  4. package/dist/{code-visibility-BKSYKzYN.js → code-visibility-MOM9Wlr-.js} +2 -2
  5. package/dist/{common-Dyo2DjUU.js → common-B0BBR0SU.js} +3 -3
  6. package/dist/{data-grid-overlay-editor-WZUSaLCR.js → data-grid-overlay-editor-CJEI4QvP.js} +1 -1
  7. package/dist/{error-entries-iJn-Nryx.js → error-entries-CJR74llu.js} +1 -1
  8. package/dist/{feedback-button-BHfX6PJR.js → feedback-button-DB2jxg1R.js} +4 -4
  9. package/dist/{glide-data-editor-yX5MjvNo.js → glide-data-editor-BTFv0Tqv.js} +3 -3
  10. package/dist/{globals-CjGj4P9q.js → globals-C0i6s0AT.js} +1 -1
  11. package/dist/{html-to-image-kpuRfdfZ.js → html-to-image-DYuNuZZd.js} +1 -1
  12. package/dist/main.js +10 -10
  13. package/dist/{number-overlay-editor-YhB8oBoI.js → number-overlay-editor-B6lUN9Tb.js} +1 -1
  14. package/dist/{process-output-DFe2tVIY.js → process-output-BurYfV57.js} +2 -2
  15. package/dist/{reveal-component-DAp51sRP.js → reveal-component-BPoa65V1.js} +3 -3
  16. package/package.json +1 -1
  17. package/src/components/chat/acp/__tests__/agent-panel-initialization.test.tsx +383 -0
  18. package/src/components/chat/acp/__tests__/agent-panel.test.tsx +10 -8
  19. package/src/components/chat/acp/__tests__/state.test.ts +2 -2
  20. package/src/components/chat/acp/agent-panel.tsx +70 -30
  21. package/src/components/chat/acp/state.ts +1 -1
  22. package/src/core/cells/__tests__/cells.test.ts +144 -0
  23. package/src/core/cells/cells.ts +7 -17
  24. package/src/core/wasm/__tests__/bridge.test.ts +5 -4
  25. package/src/core/wasm/__tests__/runtime-config.test.ts +8 -1
  26. package/src/core/wasm/bridge.ts +7 -8
  27. package/src/core/wasm/runtime-config.ts +4 -0
  28. package/src/core/wasm/worker/constants.ts +1 -0
  29. package/src/core/wasm/worker/getController.ts +3 -1
  30. package/src/core/wasm/worker/save-worker.ts +4 -10
  31. package/src/core/wasm/worker/types.ts +0 -1
  32. package/src/core/wasm/worker/worker.ts +4 -10
@@ -0,0 +1,383 @@
1
+ /* Copyright 2026 Marimo. All rights reserved. */
2
+
3
+ import { act, fireEvent, render, screen } from "@testing-library/react";
4
+ import { Provider } from "jotai";
5
+ import { JsonRpcError, type useAcpClient } from "use-acp";
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+ import { MockRequestClient } from "@/__mocks__/requests";
8
+ import { TooltipProvider } from "@/components/ui/tooltip";
9
+ import { pendingAiPromptAtom } from "@/core/ai/state";
10
+ import { requestClientAtom } from "@/core/network/requests";
11
+ import { cwdAtom, filenameAtom } from "@/core/saving/file-state";
12
+ import { store } from "@/core/state/jotai";
13
+ import AgentPanel from "../agent-panel";
14
+ import {
15
+ addSession,
16
+ agentSessionStateAtom,
17
+ updateSessionExternalAgentSessionId,
18
+ } from "../state";
19
+ import type { AgentConnectionState, ExternalAgentSessionId } from "../types";
20
+
21
+ type Agent = NonNullable<ReturnType<typeof useAcpClient>["agent"]>;
22
+ type InitializeResponse = Awaited<ReturnType<Agent["initialize"]>>;
23
+
24
+ const { client, connect, disconnect, setActiveSessionId } = vi.hoisted(() => {
25
+ const client: {
26
+ agent: ReturnType<typeof createAgent>["agent"] | null;
27
+ connectionState: AgentConnectionState;
28
+ activeSessionId: ExternalAgentSessionId | null;
29
+ } = {
30
+ agent: null,
31
+ connectionState: { status: "connected" },
32
+ activeSessionId: null,
33
+ };
34
+ return {
35
+ client,
36
+ connect: vi.fn().mockResolvedValue(undefined),
37
+ disconnect: vi.fn(),
38
+ setActiveSessionId: (sessionId: ExternalAgentSessionId | null) => {
39
+ client.activeSessionId = sessionId;
40
+ },
41
+ };
42
+ });
43
+
44
+ vi.mock("use-acp", async (importOriginal) => ({
45
+ ...(await importOriginal<typeof import("use-acp")>()),
46
+ useAcpClient: () => ({
47
+ ...client,
48
+ connect,
49
+ disconnect,
50
+ notifications: [],
51
+ setActiveSessionId,
52
+ clearNotifications: vi.fn(),
53
+ }),
54
+ }));
55
+ vi.mock("@/components/editor/ai/add-cell-with-ai", () => ({
56
+ PromptInput: () => null,
57
+ }));
58
+ vi.mock("../session-tabs", () => ({ SessionTabs: () => null }));
59
+ vi.mock("../blocks", () => ({ ReadyToChatBlock: () => null }));
60
+
61
+ const initialized: InitializeResponse = {
62
+ protocolVersion: 1,
63
+ agentCapabilities: {},
64
+ };
65
+ const advertisedAuth: InitializeResponse = {
66
+ ...initialized,
67
+ authMethods: [{ id: "login", name: "Log in" }],
68
+ };
69
+
70
+ function createAgent() {
71
+ const initialization = Promise.withResolvers<InitializeResponse>();
72
+ const agent = {
73
+ initialize: vi
74
+ .fn<Agent["initialize"]>()
75
+ .mockReturnValue(initialization.promise),
76
+ authenticate: vi.fn<Agent["authenticate"]>().mockResolvedValue(undefined),
77
+ newSession: vi.fn<Agent["newSession"]>().mockImplementation(async () => {
78
+ client.activeSessionId = "new-session" as ExternalAgentSessionId;
79
+ return { sessionId: "new-session" };
80
+ }),
81
+ loadSession: vi
82
+ .fn<NonNullable<Agent["loadSession"]>>()
83
+ .mockImplementation(async ({ sessionId }) => {
84
+ client.activeSessionId = sessionId as ExternalAgentSessionId;
85
+ return {};
86
+ }),
87
+ cancel: vi.fn<Agent["cancel"]>().mockResolvedValue(undefined),
88
+ prompt: vi
89
+ .fn<Agent["prompt"]>()
90
+ .mockResolvedValue({ stopReason: "end_turn" }),
91
+ };
92
+ return { agent, initialization };
93
+ }
94
+
95
+ function renderPanel() {
96
+ const panel = () => (
97
+ <Provider store={store}>
98
+ <TooltipProvider>
99
+ <AgentPanel />
100
+ </TooltipProvider>
101
+ </Provider>
102
+ );
103
+ const view = render(panel());
104
+ return { ...view, refresh: () => view.rerender(panel()) };
105
+ }
106
+
107
+ function expectNoSessionRequests(
108
+ agent: ReturnType<typeof createAgent>["agent"],
109
+ ) {
110
+ expect(agent.newSession).not.toHaveBeenCalled();
111
+ expect(agent.loadSession).not.toHaveBeenCalled();
112
+ }
113
+
114
+ describe("AgentPanel initialization", () => {
115
+ beforeEach(() => {
116
+ client.agent = null;
117
+ client.activeSessionId = null;
118
+ client.connectionState = { status: "connected" };
119
+ store.set(requestClientAtom, MockRequestClient.create());
120
+ store.set(filenameAtom, "notebook.py");
121
+ store.set(cwdAtom, "/notebooks");
122
+ store.set(pendingAiPromptAtom, null);
123
+ store.set(
124
+ agentSessionStateAtom,
125
+ addSession({ sessions: [], activeTabId: null }, { agentId: "claude" }),
126
+ );
127
+ });
128
+
129
+ afterEach(() => {
130
+ store.set(pendingAiPromptAtom, null);
131
+ store.set(agentSessionStateAtom, { sessions: [], activeTabId: null });
132
+ store.set(filenameAtom, null);
133
+ store.set(cwdAtom, null);
134
+ });
135
+
136
+ it.each([null, "saved-session"])(
137
+ "waits for initialization before creating/loading %s",
138
+ async (sessionId) => {
139
+ const { agent, initialization } = createAgent();
140
+ client.agent = agent;
141
+ if (sessionId) {
142
+ store.set(agentSessionStateAtom, (state) =>
143
+ updateSessionExternalAgentSessionId(
144
+ state,
145
+ sessionId as ExternalAgentSessionId,
146
+ ),
147
+ );
148
+ }
149
+ const { refresh } = renderPanel();
150
+ expect(agent.initialize).toHaveBeenCalledOnce();
151
+ expectNoSessionRequests(agent);
152
+ expect(
153
+ screen.queryByRole("button", { name: "Restart" }),
154
+ ).not.toBeInTheDocument();
155
+
156
+ await act(async () => initialization.resolve(initialized));
157
+ expect(agent.authenticate).not.toHaveBeenCalled();
158
+ if (sessionId) {
159
+ expect(agent.loadSession).toHaveBeenCalledExactlyOnceWith({
160
+ sessionId,
161
+ cwd: "/notebooks",
162
+ mcpServers: [],
163
+ });
164
+ expect(agent.newSession).not.toHaveBeenCalled();
165
+ } else {
166
+ expect(agent.newSession).toHaveBeenCalledExactlyOnceWith({
167
+ cwd: "/notebooks",
168
+ mcpServers: [],
169
+ _meta: undefined,
170
+ });
171
+ expect(agent.loadSession).not.toHaveBeenCalled();
172
+ }
173
+ expect(
174
+ screen.getByRole("button", { name: "Restart" }),
175
+ ).toBeInTheDocument();
176
+ refresh();
177
+ expect(
178
+ agent.newSession.mock.calls.length +
179
+ agent.loadSession.mock.calls.length,
180
+ ).toBe(1);
181
+ },
182
+ );
183
+
184
+ it("uses existing CLI credentials even when the agent advertises login methods", async () => {
185
+ const { agent, initialization } = createAgent();
186
+ agent.authenticate.mockRejectedValue(new Error("Method not implemented."));
187
+ client.agent = agent;
188
+ renderPanel();
189
+
190
+ await act(async () => initialization.resolve(advertisedAuth));
191
+ expect(agent.authenticate).not.toHaveBeenCalled();
192
+ expect(agent.newSession).toHaveBeenCalledOnce();
193
+ });
194
+
195
+ it("surfaces initialization failures and offers a connection retry", async () => {
196
+ const { agent, initialization } = createAgent();
197
+ client.agent = agent;
198
+ const { refresh } = renderPanel();
199
+
200
+ await act(async () => initialization.reject(new Error("Handshake failed")));
201
+ expect(await screen.findByText("Handshake failed")).toBeInTheDocument();
202
+ expectNoSessionRequests(agent);
203
+ fireEvent.click(screen.getByRole("button", { name: "Retry connection" }));
204
+ expect(disconnect).toHaveBeenCalledOnce();
205
+ expect(connect).toHaveBeenCalledTimes(2);
206
+
207
+ const retried = createAgent();
208
+ client.agent = retried.agent;
209
+ refresh();
210
+ expect(screen.queryByText("Handshake failed")).not.toBeInTheDocument();
211
+ expectNoSessionRequests(retried.agent);
212
+ await act(async () => retried.initialization.resolve(initialized));
213
+ expect(retried.agent.newSession).toHaveBeenCalledOnce();
214
+ });
215
+
216
+ it("surfaces missing CLI credentials and lets the user retry after login", async () => {
217
+ const { agent, initialization } = createAgent();
218
+ const authError = new JsonRpcError({
219
+ code: -32_000,
220
+ message: "Authentication required",
221
+ data: { message: "Run claude /login in the terminal" },
222
+ });
223
+ agent.newSession
224
+ .mockRejectedValueOnce(authError)
225
+ .mockRejectedValueOnce(authError);
226
+ client.agent = agent;
227
+ renderPanel();
228
+
229
+ await act(async () => initialization.resolve(advertisedAuth));
230
+ expect(
231
+ await screen.findByText("Run claude /login in the terminal"),
232
+ ).toBeInTheDocument();
233
+ expect(agent.authenticate).not.toHaveBeenCalled();
234
+ await act(async () => {
235
+ fireEvent.click(screen.getByRole("button", { name: "Restart session" }));
236
+ });
237
+ expect(agent.newSession).toHaveBeenCalledTimes(2);
238
+ expect(
239
+ screen.getByText("Run claude /login in the terminal"),
240
+ ).toBeInTheDocument();
241
+ await act(async () => {
242
+ fireEvent.click(screen.getByRole("button", { name: "Restart session" }));
243
+ });
244
+ expect(agent.newSession).toHaveBeenCalledTimes(3);
245
+ expect(
246
+ screen.queryByText("Run claude /login in the terminal"),
247
+ ).not.toBeInTheDocument();
248
+ });
249
+
250
+ it("does not authenticate or start a session for an agent switched away during initialization", async () => {
251
+ const old = createAgent();
252
+ const next = createAgent();
253
+ client.agent = old.agent;
254
+ renderPanel();
255
+ act(() => {
256
+ client.agent = next.agent;
257
+ store.set(agentSessionStateAtom, (state) =>
258
+ addSession(state, { agentId: "codex" }),
259
+ );
260
+ });
261
+
262
+ await act(async () => old.initialization.resolve(advertisedAuth));
263
+ expect(old.agent.authenticate).not.toHaveBeenCalled();
264
+ expectNoSessionRequests(old.agent);
265
+ expectNoSessionRequests(next.agent);
266
+ await act(async () => next.initialization.resolve(initialized));
267
+ expect(next.agent.newSession).toHaveBeenCalledOnce();
268
+ });
269
+
270
+ it("ignores a late initialization error from the previous agent", async () => {
271
+ const old = createAgent();
272
+ const next = createAgent();
273
+ client.agent = old.agent;
274
+ renderPanel();
275
+ act(() => {
276
+ client.agent = next.agent;
277
+ store.set(agentSessionStateAtom, (state) =>
278
+ addSession(state, { agentId: "codex" }),
279
+ );
280
+ });
281
+ await act(async () => next.initialization.resolve(initialized));
282
+ await act(async () =>
283
+ old.initialization.reject(new Error("Old agent failed")),
284
+ );
285
+ expect(screen.queryByText("Old agent failed")).not.toBeInTheDocument();
286
+ expect(next.agent.newSession).toHaveBeenCalledOnce();
287
+ });
288
+
289
+ it("ignores the old handshake when the same agent reconnects before it finishes", async () => {
290
+ const { agent, initialization } = createAgent();
291
+ const reinitialization = Promise.withResolvers<InitializeResponse>();
292
+ agent.initialize
293
+ .mockReturnValueOnce(initialization.promise)
294
+ .mockReturnValueOnce(reinitialization.promise);
295
+ client.agent = agent;
296
+ const { refresh } = renderPanel();
297
+
298
+ client.connectionState = { status: "disconnected" };
299
+ refresh();
300
+ client.connectionState = { status: "connected" };
301
+ refresh();
302
+ expect(agent.initialize).toHaveBeenCalledTimes(2);
303
+ await act(async () => initialization.resolve(advertisedAuth));
304
+ expect(agent.authenticate).not.toHaveBeenCalled();
305
+ expectNoSessionRequests(agent);
306
+ await act(async () => reinitialization.resolve(initialized));
307
+ expect(agent.newSession).toHaveBeenCalledOnce();
308
+ });
309
+
310
+ it("invalidates readiness on disconnect and reinitializes before resuming", async () => {
311
+ const { agent, initialization } = createAgent();
312
+ const reinitialization = Promise.withResolvers<InitializeResponse>();
313
+ agent.initialize
314
+ .mockReturnValueOnce(initialization.promise)
315
+ .mockReturnValueOnce(reinitialization.promise);
316
+ client.agent = agent;
317
+ const { refresh } = renderPanel();
318
+ await act(async () => initialization.resolve(initialized));
319
+ expect(agent.newSession).toHaveBeenCalledOnce();
320
+
321
+ client.connectionState = { status: "disconnected" };
322
+ refresh();
323
+ client.connectionState = { status: "connected" };
324
+ refresh();
325
+ expect(agent.loadSession).not.toHaveBeenCalled();
326
+ await act(async () => reinitialization.resolve(initialized));
327
+ expect(agent.loadSession).toHaveBeenCalledExactlyOnceWith({
328
+ sessionId: "new-session",
329
+ cwd: "/notebooks",
330
+ mcpServers: [],
331
+ });
332
+ expect(agent.newSession).toHaveBeenCalledOnce();
333
+ });
334
+
335
+ it("keeps a queued prompt pending until initialization and session loading finish", async () => {
336
+ const { agent, initialization } = createAgent();
337
+ const loading = Promise.withResolvers<undefined>();
338
+ agent.loadSession.mockImplementation(async ({ sessionId }) => {
339
+ await loading.promise;
340
+ client.activeSessionId = sessionId as ExternalAgentSessionId;
341
+ return {};
342
+ });
343
+ client.agent = agent;
344
+ client.activeSessionId = "saved-session" as ExternalAgentSessionId;
345
+ store.set(agentSessionStateAtom, (state) =>
346
+ updateSessionExternalAgentSessionId(
347
+ state,
348
+ "saved-session" as ExternalAgentSessionId,
349
+ ),
350
+ );
351
+ store.set(pendingAiPromptAtom, { prompt: "Help", submit: true });
352
+ renderPanel();
353
+ expect(store.get(pendingAiPromptAtom)).toEqual({
354
+ prompt: "Help",
355
+ submit: true,
356
+ });
357
+ expect(agent.prompt).not.toHaveBeenCalled();
358
+
359
+ await act(async () => initialization.resolve(initialized));
360
+ expect(agent.loadSession).toHaveBeenCalledOnce();
361
+ expect(agent.prompt).not.toHaveBeenCalled();
362
+ expect(store.get(pendingAiPromptAtom)).toEqual({
363
+ prompt: "Help",
364
+ submit: true,
365
+ });
366
+ await act(async () => loading.resolve(undefined));
367
+ expect(agent.prompt).toHaveBeenCalledOnce();
368
+ expect(agent.loadSession.mock.invocationCallOrder[0]).toBeLessThan(
369
+ agent.prompt.mock.invocationCallOrder[0],
370
+ );
371
+ expect(store.get(pendingAiPromptAtom)).toBeNull();
372
+ });
373
+
374
+ it("does not finish initialization after unmount", async () => {
375
+ const { agent, initialization } = createAgent();
376
+ client.agent = agent;
377
+ const { unmount } = renderPanel();
378
+ unmount();
379
+ await act(async () => initialization.resolve(advertisedAuth));
380
+ expect(agent.authenticate).not.toHaveBeenCalled();
381
+ expectNoSessionRequests(agent);
382
+ });
383
+ });
@@ -90,14 +90,16 @@ vi.mock("../context-utils", async (importOriginal) => ({
90
90
  }));
91
91
  vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() }));
92
92
 
93
- function renderPanel() {
94
- return render(
93
+ async function renderPanel() {
94
+ const view = render(
95
95
  <Provider store={store}>
96
96
  <TooltipProvider>
97
97
  <AgentPanel />
98
98
  </TooltipProvider>
99
99
  </Provider>,
100
100
  );
101
+ await screen.findByRole("textbox", { name: "Prompt" });
102
+ return view;
101
103
  }
102
104
 
103
105
  function submitPrompt(prompt = "Explain this notebook") {
@@ -138,7 +140,7 @@ describe("AgentPanel prompt submission", () => {
138
140
 
139
141
  it("preserves the draft, attachment and title when the notebook is unnamed", async () => {
140
142
  store.set(filenameAtom, null);
141
- renderPanel();
143
+ await renderPanel();
142
144
  const title = store.get(selectedTabAtom)?.title;
143
145
  fireEvent.change(screen.getByLabelText("Attachment"), {
144
146
  target: {
@@ -181,7 +183,7 @@ describe("AgentPanel prompt submission", () => {
181
183
  "shows a rejected prompt and allows another submission after dismissal (%s)",
182
184
  async (error) => {
183
185
  agent.prompt.mockRejectedValueOnce(error);
184
- renderPanel();
186
+ await renderPanel();
185
187
  submitPrompt();
186
188
 
187
189
  expect(await screen.findByText(/Agent unavailable/)).toBeInTheDocument();
@@ -205,7 +207,7 @@ describe("AgentPanel prompt submission", () => {
205
207
  data: { message: "Sign in to the agent" },
206
208
  }),
207
209
  );
208
- renderPanel();
210
+ await renderPanel();
209
211
  submitPrompt();
210
212
 
211
213
  expect(await screen.findByText("Sign in to the agent")).toBeInTheDocument();
@@ -221,7 +223,7 @@ describe("AgentPanel prompt submission", () => {
221
223
  vi.mocked(parseContextFromPrompt).mockRejectedValueOnce(
222
224
  new Error("Context unavailable"),
223
225
  );
224
- renderPanel();
226
+ await renderPanel();
225
227
  submitPrompt();
226
228
 
227
229
  expect(await screen.findByText("Context unavailable")).toBeInTheDocument();
@@ -234,7 +236,7 @@ describe("AgentPanel prompt submission", () => {
234
236
 
235
237
  it("reports failures from a queued prompt through the same handler", async () => {
236
238
  agent.prompt.mockRejectedValueOnce(new Error("Queued prompt failed"));
237
- renderPanel();
239
+ await renderPanel();
238
240
  act(() =>
239
241
  store.set(pendingAiPromptAtom, {
240
242
  prompt: "Help with this cell",
@@ -255,7 +257,7 @@ describe("AgentPanel prompt submission", () => {
255
257
  it("clears busy state when a cancelled prompt settles", async () => {
256
258
  const response = Promise.withResolvers<{ stopReason: string }>();
257
259
  agent.prompt.mockReturnValueOnce(response.promise);
258
- renderPanel();
260
+ await renderPanel();
259
261
  submitPrompt();
260
262
  await waitFor(() => expect(agent.prompt).toHaveBeenCalledOnce());
261
263
  expect(screen.getByText("Agent is working...")).toBeInTheDocument();
@@ -673,14 +673,14 @@ describe("state utility functions", () => {
673
673
  it("should return correct command for claude on non-Windows", () => {
674
674
  vi.spyOn(shortcuts, "isPlatformWindows").mockReturnValue(false);
675
675
  expect(getAgentConnectionCommand("claude")).toMatchInlineSnapshot(`
676
- "npx stdio-to-ws "npx @zed-industries/claude-code-acp" --port 3017"
676
+ "npx stdio-to-ws "npx @agentclientprotocol/claude-agent-acp" --port 3017"
677
677
  `);
678
678
  });
679
679
 
680
680
  it("should return correct command for claude on Windows", () => {
681
681
  vi.spyOn(shortcuts, "isPlatformWindows").mockReturnValue(true);
682
682
  expect(getAgentConnectionCommand("claude")).toMatchInlineSnapshot(`
683
- "npx stdio-to-ws "cmd /c npx @zed-industries/claude-code-acp" --port 3017"
683
+ "npx stdio-to-ws "cmd /c npx @agentclientprotocol/claude-agent-acp" --port 3017"
684
684
  `);
685
685
  });
686
686
 
@@ -718,13 +718,28 @@ const AgentPanel: React.FC = () => {
718
718
  clearNotifications,
719
719
  } = acpClient;
720
720
 
721
+ const isConnected = connectionState.status === "connected";
722
+ const [initializedConnection, setInitializedConnection] = useState<{
723
+ agent: NonNullable<typeof agent>;
724
+ wsUrl: string;
725
+ } | null>(null);
726
+ const isAgentReady =
727
+ isConnected &&
728
+ agent !== null &&
729
+ initializedConnection?.agent === agent &&
730
+ initializedConnection.wsUrl === wsUrl;
731
+
721
732
  useEffect(() => {
722
- if (!agent) {
733
+ setInitializedConnection(null);
734
+ setActiveSessionId(null);
735
+ if (!agent || !isConnected) {
723
736
  return;
724
737
  }
738
+ let cancelled = false;
739
+ setError(null);
725
740
 
726
- const initAndAuth = async () => {
727
- const response = await agent.initialize({
741
+ const initialize = async () => {
742
+ await agent.initialize({
728
743
  protocolVersion: 1,
729
744
  clientCapabilities: {
730
745
  fs: {
@@ -734,23 +749,26 @@ const AgentPanel: React.FC = () => {
734
749
  },
735
750
  });
736
751
 
737
- // We try to authenticate with the agent if it supports it.
738
- // The user must then restart the session
739
- const authMethods = response?.authMethods;
740
- if (authMethods && authMethods.length > 0) {
741
- await agent.authenticate({ methodId: authMethods[0].id });
752
+ // Agents use credentials from their CLI login. Advertised auth methods
753
+ // describe login options, not whether authentication is required.
754
+ if (!cancelled) {
755
+ setInitializedConnection({ agent, wsUrl });
742
756
  }
743
757
  };
744
758
 
745
- initAndAuth().catch((error) => {
746
- logger.error("Failed to initialize/authenticate agent", { error });
759
+ initialize().catch((error) => {
760
+ if (!cancelled) {
761
+ logger.error("Failed to initialize agent", { error });
762
+ setError(error instanceof Error ? error : String(error));
763
+ }
747
764
  });
748
- }, [agent]);
765
+ return () => {
766
+ cancelled = true;
767
+ };
768
+ }, [agent, isConnected, wsUrl, setActiveSessionId]);
749
769
 
750
770
  // Auto-connect to agent when we have an active session, but only once per session
751
771
  useEffect(() => {
752
- setActiveSessionId(null);
753
-
754
772
  if (wsUrl === NO_WS_SET) {
755
773
  return;
756
774
  }
@@ -770,7 +788,7 @@ const AgentPanel: React.FC = () => {
770
788
  }, [wsUrl]);
771
789
 
772
790
  const handleNewSession = useEvent(async () => {
773
- if (!agent) {
791
+ if (!agent || !isAgentReady) {
774
792
  return;
775
793
  }
776
794
 
@@ -814,7 +832,7 @@ const AgentPanel: React.FC = () => {
814
832
 
815
833
  const handleResumeSession = useEvent(
816
834
  async (previousSessionId: ExternalAgentSessionId) => {
817
- if (!agent) {
835
+ if (!agent || !isAgentReady) {
818
836
  return;
819
837
  }
820
838
  logger.debug("Resuming agent session", {
@@ -825,6 +843,8 @@ const AgentPanel: React.FC = () => {
825
843
  }
826
844
  creatingOrResumingSession.current = true;
827
845
  try {
846
+ // Loading replays the agent's history through session notifications.
847
+ clearNotifications(previousSessionId);
828
848
  const loadedSession = await agent.loadSession({
829
849
  sessionId: previousSessionId,
830
850
  cwd: getCwd(),
@@ -848,12 +868,19 @@ const AgentPanel: React.FC = () => {
848
868
  },
849
869
  );
850
870
 
851
- // Create or resume a session when successfully connected
852
- const isConnected = connectionState.status === "connected";
871
+ const handleRestartSession = useEvent(async () => {
872
+ setError(null);
873
+ try {
874
+ await handleNewSession();
875
+ } catch (error) {
876
+ setError(error instanceof Error ? error : String(error));
877
+ }
878
+ });
879
+
880
+ // Create or resume a session once initialization finishes.
853
881
  const tabLastActiveSessionId = selectedTab?.externalAgentSessionId;
854
882
  useEffect(() => {
855
- // No need to do anything if we're not connected, don't have an agent, or don't have a selected tab
856
- if (!isConnected || !selectedTab || !agent) {
883
+ if (!isAgentReady || !selectedTab || !agent) {
857
884
  return;
858
885
  }
859
886
 
@@ -893,12 +920,12 @@ const AgentPanel: React.FC = () => {
893
920
 
894
921
  createOrResumeSession();
895
922
  // oxlint-disable-next-line react-hooks/exhaustive-deps
896
- }, [isConnected, agent, tabLastActiveSessionId, activeSessionId]);
923
+ }, [isAgentReady, agent, tabLastActiveSessionId, activeSessionId]);
897
924
 
898
925
  // Handler for prompt submission
899
926
  const handlePromptSubmit = useEvent(
900
927
  async (_e: KeyboardEvent | undefined, prompt: string) => {
901
- if (!activeSessionId || !agent || isLoading) {
928
+ if (!activeSessionId || !agent || !isAgentReady || isLoading) {
902
929
  return;
903
930
  }
904
931
 
@@ -983,7 +1010,7 @@ const AgentPanel: React.FC = () => {
983
1010
  !activeSessionId ||
984
1011
  !agent ||
985
1012
  isLoading ||
986
- connectionState.status !== "connected" ||
1013
+ !isAgentReady ||
987
1014
  !pendingPrompt
988
1015
  ) {
989
1016
  return;
@@ -999,7 +1026,7 @@ const AgentPanel: React.FC = () => {
999
1026
  activeSessionId,
1000
1027
  agent,
1001
1028
  isLoading,
1002
- connectionState.status,
1029
+ isAgentReady,
1003
1030
  pendingPrompt,
1004
1031
  setPendingPrompt,
1005
1032
  handlePromptSubmit,
@@ -1019,7 +1046,10 @@ const AgentPanel: React.FC = () => {
1019
1046
  logger.debug("Manual connect requested", {
1020
1047
  currentStatus: connectionState.status,
1021
1048
  });
1022
- connect();
1049
+ setError(null);
1050
+ void connect().catch((error) => {
1051
+ setError(error instanceof Error ? error : String(error));
1052
+ });
1023
1053
  });
1024
1054
 
1025
1055
  // Handler for manual disconnect
@@ -1112,14 +1142,22 @@ const AgentPanel: React.FC = () => {
1112
1142
  className="w-3/4 mx-auto mt-10"
1113
1143
  error={displayError}
1114
1144
  action={
1115
- isAuthError ? (
1145
+ !isAgentReady ? (
1116
1146
  <Button
1117
1147
  variant="linkDestructive"
1118
1148
  size="sm"
1119
1149
  onClick={() => {
1120
- setError(null);
1121
- handleNewSession();
1150
+ disconnect();
1151
+ handleManualConnect();
1122
1152
  }}
1153
+ >
1154
+ Retry connection
1155
+ </Button>
1156
+ ) : isAuthError ? (
1157
+ <Button
1158
+ variant="linkDestructive"
1159
+ size="sm"
1160
+ onClick={handleRestartSession}
1123
1161
  >
1124
1162
  Restart session
1125
1163
  </Button>
@@ -1153,14 +1191,16 @@ const AgentPanel: React.FC = () => {
1153
1191
  }
1154
1192
 
1155
1193
  const isLoadingSession =
1156
- tabLastActiveSessionId == null && connectionState.status === "connected";
1194
+ isConnected && (!isAgentReady || tabLastActiveSessionId == null);
1157
1195
  if (isLoadingSession) {
1158
1196
  return (
1159
1197
  <DelayMount milliseconds={delay}>
1160
1198
  <div className="flex items-center justify-center h-full min-h-[200px] flex-col">
1161
1199
  <Spinner size="medium" className="text-primary" />
1162
1200
  <span className="text-sm text-muted-foreground">
1163
- Creating a new session...
1201
+ {isAgentReady
1202
+ ? "Creating a new session..."
1203
+ : "Initializing agent..."}
1164
1204
  </span>
1165
1205
  </div>
1166
1206
  </DelayMount>
@@ -1232,7 +1272,7 @@ const AgentPanel: React.FC = () => {
1232
1272
  currentAgentId={selectedTab?.agentId}
1233
1273
  onConnect={handleManualConnect}
1234
1274
  onDisconnect={handleManualDisconnect}
1235
- onRestartThread={handleNewSession}
1275
+ onRestartThread={isAgentReady ? handleRestartSession : undefined}
1236
1276
  hasActiveSession={true}
1237
1277
  shouldShowConnectionControl={wsUrl !== NO_WS_SET}
1238
1278
  />