Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@
"thememirror": "^2.0.1",
"timestring": "^7.0.0",
"typescript-memoize": "^1.1.1",
"use-acp": "^0.2.3",
"use-acp": "0.2.4",
"use-resize-observer": "^9.1.0",
"vega-lite": "^5.23.0",
"vega-loader": "^4.5.3",
Expand Down
56 changes: 56 additions & 0 deletions frontend/src/components/chat/acp/__tests__/state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,62 @@ describe("state utility functions", () => {
`);
});

it("should clear externalAgentSessionId when switching between different agents", () => {
// Start with a Claude session that has an active external session ID
const claudeSession: AgentSession = {
agentId: "claude",
tabId: "tab_claude" as TabId,
title: "Claude session",
createdAt: 1_735_689_600_000,
lastUsedAt: 1_735_689_600_000,
externalAgentSessionId: "claude-session-123" as ExternalAgentSessionId,
};
const initialState: AgentSessionState = {
sessions: [claudeSession],
activeTabId: claudeSession.tabId,
};

// Switch to Gemini
const newState = addSession(initialState, { agentId: "gemini" });

// Should create a new Gemini session with null externalAgentSessionId
// and remove the Claude session (MAX_SESSIONS = 1)
expect(newState.sessions).toHaveLength(1);
expect(newState.sessions[0].agentId).toBe("gemini");
expect(newState.sessions[0].externalAgentSessionId).toBe(null);

// The active tab should be the new Gemini session
expect(newState.activeTabId).toBe(newState.sessions[0].tabId);
});

it("should clear externalAgentSessionId when switching from Gemini to Claude", () => {
// Start with a Gemini session that has an active external session ID
const geminiSession: AgentSession = {
agentId: "gemini",
tabId: "tab_gemini" as TabId,
title: "Gemini session",
createdAt: 1_735_689_600_000,
lastUsedAt: 1_735_689_600_000,
externalAgentSessionId: "gemini-session-456" as ExternalAgentSessionId,
};
const initialState: AgentSessionState = {
sessions: [geminiSession],
activeTabId: geminiSession.tabId,
};

// Switch to Claude
const newState = addSession(initialState, { agentId: "claude" });

// Should create a new Claude session with null externalAgentSessionId
// and remove the Gemini session (MAX_SESSIONS = 1)
expect(newState.sessions).toHaveLength(1);
expect(newState.sessions[0].agentId).toBe("claude");
expect(newState.sessions[0].externalAgentSessionId).toBe(null);

// The active tab should be the new Claude session
expect(newState.activeTabId).toBe(newState.sessions[0].tabId);
});

it("should not mutate original state", () => {
const initialState: AgentSessionState = {
sessions: [],
Expand Down
232 changes: 143 additions & 89 deletions frontend/src/components/chat/acp/agent-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,14 @@
SelectTrigger,
} from "@/components/ui/select";
import { toast } from "@/components/ui/use-toast";
import { DelayMount } from "@/components/utils/delay-mount";
import { useRequestClient } from "@/core/network/requests";
import { filenameAtom } from "@/core/saving/file-state";
import { store } from "@/core/state/jotai";
import { Functions } from "@/utils/functions";
import { Paths } from "@/utils/paths";
import { FileAttachmentPill } from "../chat-components";
import { ReadyToChatBlock } from "./blocks";
import {
convertFilesToResourceLinks,
parseContextFromPrompt,
Expand Down Expand Up @@ -548,6 +550,52 @@
}
}, [notifications.length, isScrolledToBottom, scrollToBottom]);

const renderThread = () => {
if (hasNotifications) {
return (
<AgentThread
isConnected={connectionState.status === "connected"}
notifications={notifications}
onRetryConnection={onRetryConnection}
onRetryLastAction={onRetryLastAction}
/>
);
}

const isConnected = connectionState.status === "connected";
if (isConnected) {
return <ReadyToChatBlock />;
}

return (
<div className="flex items-center justify-center h-full min-h-[200px] flex-col">
<PanelEmptyState
title="Waiting for agent"
description="Your AI agent will appear here when active"
icon={<BotMessageSquareIcon />}
/>
{isDisconnected && agentId && (
<AgentDocs
className="border-t pt-6 px-5"
title="Make sure you're connected to an agent"
description="Run this command in your terminal:"
agents={[agentId]}
/>
)}
{isDisconnected && (
<Button
variant="outline"
onClick={onRetryConnection}
type="button"
className="mt-4"
>
Retry
</Button>
)}
</div>
);
};

return (
<div className="flex-1 flex flex-col overflow-hidden flex-shrink-0 relative">
{pendingPermission && (
Expand All @@ -569,43 +617,7 @@
Session ID: {sessionId}
</div>
)}
{hasNotifications ? (
<div className="space-y-2">
<AgentThread
isConnected={connectionState.status === "connected"}
notifications={notifications}
onRetryConnection={onRetryConnection}
onRetryLastAction={onRetryLastAction}
onDismissError={onDismissError}
/>
</div>
) : (
<div className="flex items-center justify-center h-full min-h-[200px] flex-col">
<PanelEmptyState
title="Waiting for agent"
description="Your AI agent will appear here when active"
icon={<BotMessageSquareIcon />}
/>
{isDisconnected && agentId && (
<AgentDocs
className="border-t pt-6 px-5"
title="Make sure you're connected to an agent"
description="Run this command in your terminal:"
agents={[agentId]}
/>
)}
{isDisconnected && (
<Button
variant="outline"
onClick={onRetryConnection}
type="button"
className="mt-4"
>
Retry
</Button>
)}
</div>
)}
{renderThread()}
</div>

<ScrollToBottomButton
Expand Down Expand Up @@ -671,6 +683,7 @@
const {
connect,
disconnect,
setActiveSessionId,
connectionState,
notifications,
pendingPermission,
Expand All @@ -695,32 +708,34 @@

// Auto-connect to agent when we have an active session, but only once per session
useEffect(() => {
setActiveSessionId(null);

if (wsUrl === NO_WS_SET) {
return;
}

logger.debug("Auto-connecting to agent", {
sessionId: activeSessionId,
});
connect();
void connect().catch((error) => {
logger.error("Failed to connect to agent", { error });
});

return () => {
// We don't want to disconnect so users can switch between different
// panels without losing their session
};
// eslint-disable-next-line react-hooks/exhaustive-deps

Check warning on line 728 in frontend/src/components/chat/acp/agent-panel.tsx

View workflow job for this annotation

GitHub Actions / 🧹 Lint frontend

React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior
}, [wsUrl]);

const handleNewSession = useEvent(async () => {
if (isCreatingNewSession.current) {
return;
}
if (!agent) {
return;
}

// If there is an active session, we should stop it
if (activeSessionId) {
setActiveSessionId(null);
await agent.cancel({ sessionId: activeSessionId }).catch((error) => {
logger.error("Failed to cancel active session", { error });
});
Expand Down Expand Up @@ -805,7 +820,7 @@
};

createOrResumeSession();
// eslint-disable-next-line react-hooks/exhaustive-deps

Check warning on line 823 in frontend/src/components/chat/acp/agent-panel.tsx

View workflow job for this annotation

GitHub Actions / 🧹 Lint frontend

React Compiler has skipped optimizing this component because one or more React ESLint rules were disabled. React Compiler only works when your components follow all the rules of React, disabling them may result in unexpected or incorrect behavior
}, [isConnected, agent, tabLastActiveSessionId, activeSessionId]);

// Handler for prompt submission
Expand Down Expand Up @@ -969,6 +984,92 @@
);
}

const renderBody = () => {
const isConnecting = connectionState.status === "connecting";
const delay = 200; // ms
if (isConnecting) {
return (
<DelayMount milliseconds={delay}>
<div className="flex items-center justify-center h-full min-h-[200px] flex-col">
<Spinner size="medium" className="text-primary" />
<span className="text-sm text-muted-foreground">
Connecting to the agent...
</span>
</div>
</DelayMount>
);
}

const isLoadingSession =
tabLastActiveSessionId == null && connectionState.status === "connected";
if (isLoadingSession) {
return (
<DelayMount milliseconds={delay}>
<div className="flex items-center justify-center h-full min-h-[200px] flex-col">
<Spinner size="medium" className="text-primary" />
<span className="text-sm text-muted-foreground">
Creating a new session...
</span>
</div>
</DelayMount>
);
}

return (
<>
<ChatContent
key={activeSessionId}
agentId={selectedTab?.agentId}
sessionId={selectedTab?.externalAgentSessionId ?? null}
hasNotifications={hasNotifications}
connectionState={connectionState}
notifications={notifications}
pendingPermission={pendingPermission}
onResolvePermission={(option) => {
logger.debug("Resolving permission request", {
sessionId: activeSessionId,
option,
});
resolvePermission(option);
}}
onRetryConnection={handleManualConnect}
/>

<LoadingIndicator
isLoading={isLoading}
isRequestingPermission={!!pendingPermission}
onStop={handleStop}
/>

{files && files.length > 0 && (
<div className="flex flex-row gap-1 flex-wrap p-3 border-t">
{files.map((file) => (
<FileAttachmentPill
file={file}
key={file.name}
onRemove={() => handleRemoveFile(file)}
/>
))}
</div>
)}

<PromptArea
isLoading={isLoading}
activeSessionId={activeSessionId}
promptValue={promptValue}
onPromptValueChange={setPromptValue}
onPromptSubmit={handlePromptSubmit}
onAddFiles={handleAddFiles}
onStop={handleStop}
fileInputRef={fileInputRef}
commands={availableCommands}
sessionMode={sessionMode}
onModeChange={handleModeChange}
/>
</>
);
};

return (
<div className="flex flex-col flex-1 overflow-hidden mo-agent-panel">
<AgentPanelHeader
Expand All @@ -982,54 +1083,7 @@
/>
<SessionTabs />

<ChatContent
agentId={selectedTab?.agentId}
sessionId={activeSessionId}
hasNotifications={hasNotifications}
connectionState={connectionState}
notifications={notifications}
pendingPermission={pendingPermission}
onResolvePermission={(option) => {
logger.debug("Resolving permission request", {
sessionId: activeSessionId,
option,
});
resolvePermission(option);
}}
onRetryConnection={handleManualConnect}
/>

<LoadingIndicator
isLoading={isLoading}
isRequestingPermission={!!pendingPermission}
onStop={handleStop}
/>

{files && files.length > 0 && (
<div className="flex flex-row gap-1 flex-wrap p-3 border-t">
{files.map((file) => (
<FileAttachmentPill
file={file}
key={file.name}
onRemove={() => handleRemoveFile(file)}
/>
))}
</div>
)}

<PromptArea
isLoading={isLoading}
activeSessionId={activeSessionId}
promptValue={promptValue}
onPromptValueChange={setPromptValue}
onPromptSubmit={handlePromptSubmit}
onAddFiles={handleAddFiles}
onStop={handleStop}
fileInputRef={fileInputRef}
commands={availableCommands}
sessionMode={sessionMode}
onModeChange={handleModeChange}
/>
{renderBody()}
</div>
);
};
Expand Down
Loading
Loading