fix(web): disable resume and show agent selector when profile is deleted (#578)

This commit is contained in:
Luan Martins
2026-04-15 22:31:02 +01:00
committed by GitHub
parent 1f4451b192
commit 59d7270056
5 changed files with 264 additions and 30 deletions
@@ -114,7 +114,7 @@ func (r *sqliteRepository) initSchema() error {
}
// migrateDropModelCheckConstraint recreates agent_profiles without the legacy
// CHECK(model != '') constraint. Existing databases created before the ACP-first
// CHECK(model != ”) constraint. Existing databases created before the ACP-first
// migration carry this constraint, which prevents empty model values. New
// databases (created by the CREATE TABLE IF NOT EXISTS above) never have it.
//
@@ -3,7 +3,9 @@
import { forwardRef, useCallback, useState } from "react";
import { IconAlertTriangle, IconPlus, IconPlayerPlay } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { NewSessionDialog } from "@/components/task/new-session-dialog";
import { useAppStore } from "@/components/state-provider";
import type { ContextFile } from "@/lib/state/context-files-store";
import type { Message } from "@/lib/types/http";
import type { DiffComment } from "@/lib/diff/types";
@@ -93,6 +95,15 @@ function FailedSessionBanner({
}) {
const [isResuming, setIsResuming] = useState(false);
const agentProfileId = useAppStore((s) =>
sessionId ? (s.taskSessions.items[sessionId]?.agent_profile_id ?? "") : "",
);
const profileExists = useAppStore(
(s) =>
agentProfileId !== "" &&
s.agentProfiles.items.some((p: { id: string }) => p.id === agentProfileId),
);
const handleResume = useCallback(async () => {
if (!sessionId || !taskId) return;
const client = getWebSocketClient();
@@ -119,16 +130,24 @@ function FailedSessionBanner({
</div>
<div className="flex items-center gap-2">
{sessionId && taskId && (
<Button
variant="default"
size="sm"
className="shrink-0 gap-1.5 cursor-pointer"
onClick={handleResume}
disabled={isResuming}
>
<IconPlayerPlay className="h-3.5 w-3.5" />
{isResuming ? "Resuming..." : "Resume"}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex" data-testid="failed-session-resume-wrapper">
<Button
variant="default"
size="sm"
data-testid="failed-session-resume-button"
className="shrink-0 gap-1.5 cursor-pointer"
onClick={handleResume}
disabled={isResuming || !profileExists}
>
<IconPlayerPlay className="h-3.5 w-3.5" />
{isResuming ? "Resuming..." : "Resume"}
</Button>
</span>
</TooltipTrigger>
{!profileExists && <TooltipContent>Agent profile no longer exists</TooltipContent>}
</Tooltip>
)}
<Button
variant="outline"
+57 -19
View File
@@ -71,7 +71,22 @@ function useNewSessionDialogState(taskId: string) {
return executor?.name ?? null;
});
return { taskTitle, agentProfiles, currentSession, worktreeBranch, initialPrompt, executorLabel };
const sessionProfileId = currentSession?.agent_profile_id ?? "";
const profileIsValid = agentProfiles.some((p: { id: string }) => p.id === sessionProfileId);
const effectiveDefaultProfileId: string = profileIsValid
? sessionProfileId
: (agentProfiles[0]?.id ?? "");
return {
taskTitle,
agentProfiles,
currentSession,
worktreeBranch,
initialPrompt,
executorLabel,
sessionProfileId,
effectiveDefaultProfileId,
};
}
function activateNewSession(
@@ -111,6 +126,7 @@ function useSessionOptions(taskId: string) {
function NewSessionForm({
taskId,
defaultProfileId,
initialProfileId,
executorId,
executorLabel,
worktreeBranch,
@@ -121,6 +137,7 @@ function NewSessionForm({
}: {
taskId: string;
defaultProfileId: string;
initialProfileId?: string;
executorId: string;
executorLabel: string | null;
worktreeBranch: string | null;
@@ -134,7 +151,7 @@ function NewSessionForm({
const { summarize, isSummarizing } = useSummarizeSession();
const [isCreating, setIsCreating] = useState(false);
const [contextValue, setContextValue] = useState("blank");
const [selectedProfileId, setSelectedProfileId] = useState(defaultProfileId);
const [selectedProfileId, setSelectedProfileId] = useState(initialProfileId ?? defaultProfileId);
const [hasPrompt, setHasPrompt] = useState(false);
const promptRef = useRef<HTMLTextAreaElement>(null);
const {
@@ -167,6 +184,12 @@ function NewSessionForm({
}
});
}, [enhancePrompt]);
const hasProfiles = profileOptions.length > 0;
const showAgentSelector =
hasProfiles &&
(profileOptions.length > 1 ||
(!!defaultProfileId && !profileOptions.find((o) => o.value === defaultProfileId)));
const isBusy = isCreating || isSummarizing;
const handleContextChange = useCallback(
async (value: string) => {
@@ -207,7 +230,7 @@ function NewSessionForm({
if (!prompt) return;
setIsCreating(true);
try {
const { request } = buildStartRequest(taskId, selectedProfileId || defaultProfileId, {
const { request } = buildStartRequest(taskId, selectedProfileId, {
executorId,
prompt,
attachments: toMessageAttachments(attachments),
@@ -216,9 +239,7 @@ function NewSessionForm({
if (!response.session_id) {
throw new Error("Session created but no session ID returned");
}
const profile = agentProfiles.find(
(p: AgentProfileOption) => p.id === (selectedProfileId || defaultProfileId),
);
const profile = agentProfiles.find((p: AgentProfileOption) => p.id === selectedProfileId);
activateNewSession(
response.session_id,
taskId,
@@ -240,7 +261,6 @@ function NewSessionForm({
[
taskId,
selectedProfileId,
defaultProfileId,
executorId,
contextValue,
initialPrompt,
@@ -253,17 +273,20 @@ function NewSessionForm({
],
);
const showSessions = sessionOptions;
return (
<form onSubmit={handleSubmit} className="space-y-4">
<EnvironmentBadges executorLabel={executorLabel} worktreeBranch={worktreeBranch} />
{profileOptions.length > 1 && (
{!hasProfiles && (
<p className="text-xs text-center text-muted-foreground">
No agent profiles configured. Add one in Settings → Agents first.
</p>
)}
{showAgentSelector && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Agent Profile</label>
<AgentSelector
options={profileOptions}
value={selectedProfileId || defaultProfileId}
value={selectedProfileId}
onValueChange={setSelectedProfileId}
disabled={isCreating}
placeholder="Select agent profile"
@@ -274,7 +297,7 @@ function NewSessionForm({
value={contextValue}
onValueChange={handleContextChange}
hasInitialPrompt={!!initialPrompt}
sessionOptions={showSessions}
sessionOptions={sessionOptions}
isSummarizing={isSummarizing}
/>
<div
@@ -290,18 +313,24 @@ function NewSessionForm({
placeholder="What should the agent work on?"
className="border-0 focus-visible:ring-0 focus-visible:ring-offset-0 min-h-[120px] max-h-[240px] resize-none overflow-auto text-[13px]"
autoFocus
disabled={isCreating || isSummarizing}
disabled={isBusy}
onInput={(e) => setHasPrompt(!!e.currentTarget.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey) &&
!isBusy &&
hasPrompt &&
hasProfiles
) {
e.preventDefault();
handleSubmit(e);
}
}}
/>
<div className="flex items-center px-1 pb-1">
<AttachButton onClick={handleAttachClick} disabled={isCreating || isSummarizing} />
<AttachButton onClick={handleAttachClick} disabled={isBusy} />
<EnhancePromptButton
onClick={handleEnhancePrompt}
isLoading={isEnhancingPrompt}
@@ -343,7 +372,7 @@ function NewSessionForm({
</Button>
<Button
type="submit"
disabled={isCreating || isSummarizing || !hasPrompt}
disabled={isBusy || !hasPrompt || !hasProfiles}
className="cursor-pointer"
>
{isCreating ? "Creating..." : "Start Agent"}
@@ -354,8 +383,16 @@ function NewSessionForm({
}
export function NewSessionDialog({ open, onOpenChange, taskId, groupId }: NewSessionDialogProps) {
const { taskTitle, agentProfiles, currentSession, worktreeBranch, initialPrompt, executorLabel } =
useNewSessionDialogState(taskId);
const {
taskTitle,
agentProfiles,
currentSession,
worktreeBranch,
initialPrompt,
executorLabel,
sessionProfileId,
effectiveDefaultProfileId,
} = useNewSessionDialogState(taskId);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -368,7 +405,8 @@ export function NewSessionDialog({ open, onOpenChange, taskId, groupId }: NewSes
<NewSessionForm
key={`${open}`}
taskId={taskId}
defaultProfileId={currentSession?.agent_profile_id ?? ""}
defaultProfileId={sessionProfileId}
initialProfileId={effectiveDefaultProfileId}
executorId={currentSession?.executor_id ?? ""}
executorLabel={executorLabel}
worktreeBranch={worktreeBranch}
+18
View File
@@ -61,6 +61,18 @@ export class SessionPage {
todoIndicator() {
return this.page.getByTestId("todo-indicator");
}
/** Resume button shown in the FailedSessionBanner. */
failedSessionResumeButton(): Locator {
return this.page.getByTestId("failed-session-resume-button");
}
/** Span wrapper around the resume button — used to trigger tooltip on disabled state. */
failedSessionResumeWrapper(): Locator {
return this.page.getByTestId("failed-session-resume-wrapper");
}
/** Cancel button shown in the chat toolbar while an agent turn is running. */
cancelAgentButton(): Locator {
return this.page.getByTestId("cancel-agent-button");
}
async waitForLoad(timeout = 15_000) {
// When multiple session tabs are open, multiple session-chat panels exist in
@@ -550,6 +562,12 @@ export class SessionPage {
await tab.click({ button: "right" });
}
/** Right-click the first session tab (useful when there is only one session). */
async rightClickFirstSessionTab(): Promise<void> {
const tab = this.page.locator("[data-testid^='session-tab-']").first();
await tab.click({ button: "right" });
}
/** Context menu item by visible label. */
contextMenuItem(label: string): Locator {
return this.page.getByRole("menuitem", { name: label });
@@ -0,0 +1,159 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
/** Navigate to a kanban card by title and open its session page. */
async function openTaskSession(page: Parameters<typeof KanbanPage>[0], title: string) {
const kanban = new KanbanPage(page);
await kanban.goto();
const card = kanban.taskCardByTitle(title);
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
await expect(page).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(page);
await session.waitForLoad();
// Reload to get fresh SSR state — the session may have started via API before
// the WS connection was established, causing the UI to miss "session is RUNNING"
// events. A reload guarantees hydrated state from the server.
await page.reload();
await session.waitForLoad();
return session;
}
/**
* Stop the running session via the tab context menu (session.stop → CANCELLED state),
* then wait for the FailedSessionBanner to appear.
*
* Note: clicking the cancel-agent-button sends `agent.cancel` which only cancels
* the current turn and transitions to WAITING_FOR_INPUT (not CANCELLED/FAILED).
* To reach CANCELLED we need `session.stop` which is triggered via right-click → Stop.
*/
async function stopRunningSession(session: SessionPage) {
// Wait for the agent to be actively running (cancel button becomes visible)
await expect(session.cancelAgentButton()).toBeVisible({ timeout: 30_000 });
// Right-click the session tab to open context menu, then click "Stop"
// This sends session.stop → CANCELLED state
await session.rightClickFirstSessionTab();
await session.contextMenuItem("Stop").click();
// Wait for the FailedSessionBanner's resume button to appear (isFailed = FAILED | CANCELLED)
await expect(session.failedSessionResumeButton()).toBeVisible({ timeout: 30_000 });
}
test.describe("New session with deleted agent profile", () => {
test("resume button is disabled when agent profile was deleted", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(90_000);
// 1. Get agent info and create a temporary custom profile
const { agents } = await apiClient.listAgents();
const agent = agents[0];
if (!agent) throw new Error("No agents available");
const profile = await apiClient.createAgentProfile(agent.id, "Temp Profile for Delete Test", {
model: agent.profiles[0]?.model ?? "mock",
auto_approve: true,
});
// 2. Create task using the custom profile with a slow response so we can cancel it
await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Deleted Profile Resume Test",
profile.id,
{
description: "/slow 60s",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
// 3. Navigate to the task and stop the agent to get CANCELLED state
const session = await openTaskSession(testPage, "Deleted Profile Resume Test");
await stopRunningSession(session);
// 4. While on the page, force-delete the custom agent profile.
// The frontend receives an "agent.profile.deleted" WS event and removes
// the profile from the store, making profileExists=false reactively.
await apiClient.deleteAgentProfile(profile.id, true);
// 5. Wait for the resume button to become disabled (store has updated)
await expect(session.failedSessionResumeButton()).toBeDisabled({ timeout: 10_000 });
// 6. Hover the span wrapper (tooltip trigger) and verify the tooltip text
await testPage.getByTestId("failed-session-resume-wrapper").hover();
await expect(testPage.getByRole("tooltip")).toContainText("Agent profile no longer exists", {
timeout: 5_000,
});
});
test("new agent dialog falls back to available profile when original was deleted", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
// 1. Get agent info and create a temporary custom profile
const { agents } = await apiClient.listAgents();
const agent = agents[0];
if (!agent) throw new Error("No agents available");
const profile = await apiClient.createAgentProfile(agent.id, "Temp Profile for Dialog Test", {
model: agent.profiles[0]?.model ?? "mock",
auto_approve: true,
});
// 2. Create task using the custom profile with a slow response so we can cancel it
await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Deleted Profile Dialog Test",
profile.id,
{
description: "/slow 60s",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
// 3. Navigate to the task and stop the agent to get CANCELLED state
const session = await openTaskSession(testPage, "Deleted Profile Dialog Test");
await stopRunningSession(session);
// 4. While on the page, force-delete the custom agent profile.
// The frontend receives an "agent.profile.deleted" WS event and removes
// the profile from the store.
await apiClient.deleteAgentProfile(profile.id, true);
// 5. Wait for the resume button to become disabled (confirms store updated)
await expect(session.failedSessionResumeButton()).toBeDisabled({ timeout: 10_000 });
// 6. Click the "New Agent" button in the FailedSessionBanner to open the dialog
await testPage.getByRole("button", { name: "New Agent" }).click();
// 7. Dialog should be visible
await expect(session.newSessionDialog()).toBeVisible({ timeout: 5_000 });
// 8. Agent selector should be visible because the original profile was deleted
// (isDefaultProfileMissing=true forces the selector to show even with 1 profile)
const agentSelector = session.newSessionDialog().getByTestId("agent-profile-selector");
await expect(agentSelector).toBeVisible();
// 9. Fill in a prompt and submit to create a new session with the fallback profile
await session.newSessionPromptInput().fill("/e2e:simple-message");
await session.newSessionStartButton().click();
// 10. Dialog should close after submit
await expect(session.newSessionDialog()).not.toBeVisible({ timeout: 10_000 });
// 11. Verify the new session tab is visible
await expect(session.sessionTabByText("2")).toBeVisible({ timeout: 15_000 });
});
});