feat(web): add session handoff via agent tab context menu (#1317)

This commit is contained in:
Carlos Florêncio
2026-06-09 16:42:05 +01:00
committed by GitHub
parent 71bf90d9e0
commit b3da5f9af1
16 changed files with 1720 additions and 241 deletions
@@ -0,0 +1,105 @@
import { renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { AgentProfileOption } from "@/lib/state/slices";
import type { ExecutorProfile } from "@/lib/types/http";
const PROFILE_A: AgentProfileOption = {
id: "profile-a",
label: "Mock Agent \u2022 Fast",
agent_name: "mock",
agent_id: "agent-1",
cli_passthrough: false,
};
const PROFILE_B: AgentProfileOption = {
id: "profile-b",
label: "Mock Agent \u2022 Slow",
agent_name: "mock",
agent_id: "agent-1",
cli_passthrough: false,
};
let mockProfiles: AgentProfileOption[] = [PROFILE_A, PROFILE_B];
let mockExecutorProfile: ExecutorProfile | null = null;
let mockAuthLoaded = true;
let mockAuthSpecs: Record<string, unknown> = {};
const mockUseTaskExecutorProfile = vi.fn(
(_taskId: string, _enabled?: boolean) => mockExecutorProfile,
);
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
agentProfiles: { items: mockProfiles },
}),
}));
vi.mock("@/hooks/domains/session/use-task-executor-profile", () => ({
useTaskExecutorProfile: (taskId: string, enabled?: boolean) =>
mockUseTaskExecutorProfile(taskId, enabled),
}));
vi.mock("@/hooks/domains/settings/use-remote-auth-specs", () => ({
useRemoteAuthSpecs: () => ({ specs: mockAuthSpecs, loaded: mockAuthLoaded }),
}));
vi.mock("@/lib/agent-executor-compat", () => ({
isAgentConfiguredOnExecutor: (
profile: AgentProfileOption,
_executor: ExecutorProfile,
_specs: Record<string, unknown>,
) => profile.id === "profile-a",
}));
import { useHandoffProfiles } from "./handoff-profile-menu-items";
describe("useHandoffProfiles", () => {
afterEach(() => {
mockProfiles = [PROFILE_A, PROFILE_B];
mockExecutorProfile = null;
mockAuthLoaded = true;
mockAuthSpecs = {};
mockUseTaskExecutorProfile.mockClear();
});
it("returns all agent profiles with display labels", () => {
const { result } = renderHook(() => useHandoffProfiles("task-1"));
expect(result.current).toHaveLength(2);
expect(result.current[0]).toMatchObject({
id: "profile-a",
label: "Fast",
agentName: "mock",
});
expect(result.current[1]).toMatchObject({
id: "profile-b",
label: "Slow",
});
});
it("marks incompatible profiles disabled when executor profile is known", () => {
mockExecutorProfile = {
id: "exec-profile-1",
name: "Default",
executor_id: "executor-1",
executor_type: "local_pc",
prepare_script: "",
cleanup_script: "",
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
const { result } = renderHook(() => useHandoffProfiles("task-1"));
expect(result.current.find((p) => p.id === "profile-a")?.disabled).toBe(false);
expect(result.current.find((p) => p.id === "profile-b")?.disabled).toBe(true);
});
it("returns empty list when no profiles configured", () => {
mockProfiles = [];
const { result } = renderHook(() => useHandoffProfiles("task-1"));
expect(result.current).toEqual([]);
});
it("passes the enabled flag to executor profile lookup", () => {
renderHook(() => useHandoffProfiles("task-1", false));
expect(mockUseTaskExecutorProfile).toHaveBeenCalledWith("task-1", false);
});
});
@@ -0,0 +1,146 @@
"use client";
import { useMemo, useState } from "react";
import { AgentLogo } from "@/components/agent-logo";
import {
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from "@kandev/ui/context-menu";
import {
DropdownMenuItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@kandev/ui/dropdown-menu";
import { useAppStore } from "@/components/state-provider";
import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs";
import { useTaskExecutorProfile } from "@/hooks/domains/session/use-task-executor-profile";
import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat";
import type { AgentProfileOption } from "@/lib/state/slices";
export type HandoffProfile = {
id: string;
label: string;
agentName?: string;
disabled: boolean;
};
function profileDisplayLabel(profile: AgentProfileOption): { label: string; agentName: string } {
const parts = profile.label.split(" \u2022 ");
const agentLabel =
parts.length > 1 ? parts.slice(1).join(" \u2022 ") : (parts[0] ?? profile.label);
return {
label: agentLabel,
agentName: profile.agent_name,
};
}
export function useHandoffProfiles(taskId: string, enabled = true): HandoffProfile[] {
const agentProfiles = useAppStore((s) => s.agentProfiles.items);
const executorProfile = useTaskExecutorProfile(taskId, enabled);
const { specs: authSpecs, loaded: authLoaded } = useRemoteAuthSpecs();
return useMemo(() => {
return agentProfiles.map((profile) => {
const { label, agentName } = profileDisplayLabel(profile);
let disabled = false;
if (executorProfile && authLoaded) {
disabled = !isAgentConfiguredOnExecutor(profile, executorProfile, authSpecs);
}
return { id: profile.id, label, agentName, disabled };
});
}, [agentProfiles, executorProfile, authSpecs, authLoaded]);
}
function HandoffProfileList({
profiles,
onSelectProfile,
Item,
}: {
profiles: HandoffProfile[];
onSelectProfile: (profileId: string) => void;
Item: typeof ContextMenuItem | typeof DropdownMenuItem;
}) {
if (profiles.length === 0) {
return (
<Item disabled className="text-xs text-muted-foreground">
No agent profiles configured
</Item>
);
}
return profiles.map((profile) => (
<Item
key={profile.id}
className="cursor-pointer"
disabled={profile.disabled}
title={profile.disabled ? "Not configured for this executor" : undefined}
data-testid={`handoff-profile-${profile.id}`}
onSelect={() => onSelectProfile(profile.id)}
>
<span className="inline-flex items-center gap-1.5">
{profile.agentName && (
<AgentLogo agentName={profile.agentName} size={14} className="shrink-0" />
)}
{profile.label}
</span>
</Item>
));
}
type HandoffMenuProps = {
taskId: string;
disabled?: boolean;
onSelectProfile: (profileId: string) => void;
};
export function HandoffContextMenuSub({ taskId, disabled, onSelectProfile }: HandoffMenuProps) {
const [submenuOpen, setSubmenuOpen] = useState(false);
const profiles = useHandoffProfiles(taskId, submenuOpen);
const submenuDisabled = disabled || (profiles.length > 0 && profiles.every((p) => p.disabled));
return (
<ContextMenuSub open={submenuOpen} onOpenChange={setSubmenuOpen}>
<ContextMenuSubTrigger
className="cursor-pointer"
disabled={submenuDisabled}
data-testid="session-handoff-submenu"
>
Handoff
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
<HandoffProfileList
profiles={profiles}
onSelectProfile={onSelectProfile}
Item={ContextMenuItem}
/>
</ContextMenuSubContent>
</ContextMenuSub>
);
}
export function HandoffDropdownMenuSub({ taskId, disabled, onSelectProfile }: HandoffMenuProps) {
const [submenuOpen, setSubmenuOpen] = useState(false);
const profiles = useHandoffProfiles(taskId, submenuOpen);
const submenuDisabled = disabled || (profiles.length > 0 && profiles.every((p) => p.disabled));
return (
<DropdownMenuSub open={submenuOpen} onOpenChange={setSubmenuOpen}>
<DropdownMenuSubTrigger
className="cursor-pointer"
disabled={submenuDisabled}
data-testid="session-handoff-submenu"
>
Handoff
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-48">
<HandoffProfileList
profiles={profiles}
onSelectProfile={onSelectProfile}
Item={DropdownMenuItem}
/>
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { buildHandoffInitialState, summarizeContextValue } from "./handoff-types";
describe("handoff-types", () => {
it("buildHandoffInitialState selects target profile and summarize context", () => {
const result = buildHandoffInitialState({
sourceSessionId: "session-a",
targetProfileId: "profile-b",
});
expect(result).toEqual({
selectedProfileId: "profile-b",
contextValue: "summarize:session-a",
});
});
it("summarizeContextValue prefixes session id", () => {
expect(summarizeContextValue("session-123")).toBe("summarize:session-123");
});
});
+18
View File
@@ -0,0 +1,18 @@
export type HandoffPreset = {
sourceSessionId: string;
targetProfileId: string;
};
export function buildHandoffInitialState(handoff: HandoffPreset): {
selectedProfileId: string;
contextValue: string;
} {
return {
selectedProfileId: handoff.targetProfileId,
contextValue: summarizeContextValue(handoff.sourceSessionId),
};
}
export function summarizeContextValue(sessionId: string): string {
return `summarize:${sessionId}`;
}
@@ -29,7 +29,8 @@ import {
isSessionDeletable,
isSessionResumable,
} from "@/hooks/domains/session/use-session-actions";
import { NewSessionDialog } from "../new-session-dialog";
import { HandoffDropdownMenuSub } from "../handoff-profile-menu-items";
import { NewSessionDialog, type HandoffPreset } from "../new-session-dialog";
import { MobilePillButton } from "./mobile-pill-button";
import { MobilePickerSheet } from "./mobile-picker-sheet";
import { formatTaskSessionStateLabel } from "@/lib/ui/state-labels";
@@ -87,20 +88,28 @@ function StateBadge({ state }: { state: TaskSessionState | null }) {
}
function SessionActionsMenu({
taskId,
state,
isPrimary,
onSetPrimary,
onStop,
onResume,
onAskDelete,
onHandoffProfile,
}: {
taskId: string;
state: TaskSessionState | null;
isPrimary: boolean;
onSetPrimary: () => void;
onStop: () => void;
onResume: () => void;
onAskDelete: () => void;
onHandoffProfile: (profileId: string) => void;
}) {
const hasLifecycleAction =
!!state &&
(isSessionStoppable(state) || isSessionResumable(state) || isSessionDeletable(state));
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -141,6 +150,8 @@ function SessionActionsMenu({
Delete
</DropdownMenuItem>
)}
{hasLifecycleAction && <DropdownMenuSeparator />}
<HandoffDropdownMenuSub taskId={taskId} onSelectProfile={onHandoffProfile} />
</DropdownMenuContent>
</DropdownMenu>
);
@@ -209,9 +220,18 @@ function SessionRowItem({
onSelect: (sessionId: string) => void;
}) {
const [confirmDelete, setConfirmDelete] = useState(false);
const [handoffOpen, setHandoffOpen] = useState(false);
const [handoffPreset, setHandoffPreset] = useState<HandoffPreset | null>(null);
const actions = useSessionActions({ sessionId: row.id, taskId });
const isOnly = totalSessions === 1;
const showBadges = totalSessions > 1;
const handleHandoffProfile = useCallback(
(profileId: string) => {
setHandoffPreset({ sourceSessionId: row.id, targetProfileId: profileId });
setHandoffOpen(true);
},
[row.id],
);
return (
<>
@@ -242,14 +262,27 @@ function SessionRowItem({
<span className="text-sm truncate flex-1">{row.agentLabel}</span>
<StateBadge state={row.state} />
<SessionActionsMenu
taskId={taskId}
state={row.state}
isPrimary={row.isPrimary}
onSetPrimary={() => void actions.setPrimary()}
onStop={() => void actions.stop()}
onResume={() => void actions.resume()}
onAskDelete={() => setConfirmDelete(true)}
onHandoffProfile={handleHandoffProfile}
/>
</div>
{handoffPreset && (
<NewSessionDialog
open={handoffOpen}
onOpenChange={(open) => {
setHandoffOpen(open);
if (!open) setHandoffPreset(null);
}}
taskId={taskId}
handoff={handoffPreset}
/>
)}
<DeleteSessionConfirmDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
+255 -233
View File
@@ -1,75 +1,48 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { RefObject } from "react";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@kandev/ui/dialog";
import { Button } from "@kandev/ui/button";
import { Textarea } from "@kandev/ui/textarea";
import { useAppStore } from "@/components/state-provider";
import { useToast } from "@/components/toast-provider";
import { launchSession } from "@/lib/services/session-launch-service";
import { buildStartRequest } from "@/lib/services/session-launch-helpers";
import { useDockviewStore } from "@/lib/state/dockview-store";
import { addSessionPanel } from "@/lib/state/dockview-panel-actions";
import { AgentSelector } from "@/components/task-create-dialog-selectors";
import { useAgentProfileOptions } from "@/components/task-create-dialog-options";
import { toMessageAttachments } from "@/components/task-create-dialog-helpers";
import { useSummarizeSession } from "@/hooks/use-summarize-session";
import { useTaskSessions } from "@/hooks/use-task-sessions";
import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs";
import { useTaskExecutorProfile } from "@/hooks/domains/session/use-task-executor-profile";
import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat";
import { fetchTaskEnvironment } from "@/lib/api/domains/task-environment-api";
import type { AgentProfileOption } from "@/lib/state/slices";
import type { ExecutorProfile } from "@/lib/types/http";
import { IconLoader2 } from "@tabler/icons-react";
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
import { buildHandoffInitialState, type HandoffPreset } from "./handoff-types";
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
import {
EnvironmentBadges,
ContextSelect,
useDialogAttachments,
AttachButton,
toContextItems,
} from "./session-dialog-shared";
import { ContextZone } from "./chat/context-items/context-zone";
import { SessionPromptField } from "./new-session-form-prompt";
import { useSessionContextChange, useSessionLaunchSubmit } from "./new-session-form-actions";
export type { HandoffPreset } from "./handoff-types";
type NewSessionDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
taskId: string;
groupId?: string;
handoff?: HandoffPreset;
};
function useTaskExecutorProfile(taskId: string, open: boolean): ExecutorProfile | null {
const executors = useAppStore((state) => state.executors.items);
const [profile, setProfile] = useState<ExecutorProfile | null>(null);
useEffect(() => {
if (!open) return;
let active = true;
void fetchTaskEnvironment(taskId)
.then((env) => {
if (!active || !env) return;
for (const executor of executors) {
const match = (executor.profiles ?? []).find((p) => p.id === env.executor_profile_id);
if (match) {
setProfile({
...match,
executor_type: match.executor_type ?? executor.type,
executor_name: match.executor_name ?? executor.name,
});
return;
}
}
})
.catch(() => {});
return () => {
active = false;
};
}, [open, taskId, executors]);
return profile;
function agentProfileDisplayLabel(profile: AgentProfileOption): string {
const parts = profile.label.split(" \u2022 ");
return parts.length > 1 ? parts.slice(1).join(" \u2022 ") : (parts[0] ?? profile.label);
}
function useNewSessionDialogState(taskId: string) {
@@ -151,8 +124,7 @@ function useSessionOptions(taskId: string) {
);
return sorted.map((s, idx) => {
const profile = agentProfiles.find((p: { id: string }) => p.id === s.agent_profile_id);
const parts = profile?.label.split(" \u2022 ");
const name = parts?.[1] || parts?.[0] || "Agent";
const name = profile ? agentProfileDisplayLabel(profile) : "Agent";
return { id: s.id, label: name, index: idx + 1, agentName: profile?.agent_name };
});
}, [sessions, agentProfiles]);
@@ -181,6 +153,51 @@ function useCompatibleAgentProfiles(
}, [agentProfiles, executorProfile, authSpecs, authLoaded]);
}
function useHandoffAutoSummarize(
handoff: HandoffPreset | undefined,
contextValue: string,
onContextChange: (value: string) => void,
) {
const started = useRef(false);
useEffect(() => {
if (!handoff || started.current) return;
started.current = true;
void onContextChange(contextValue);
}, [handoff, contextValue, onContextChange]);
}
function useSessionPromptEnhancer(
promptRef: RefObject<HTMLTextAreaElement | null>,
setHasPrompt: (value: boolean) => void,
) {
const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ sessionId: null });
const handleEnhancePrompt = useCallback(() => {
const current = promptRef.current?.value?.trim();
if (!current) return;
enhancePrompt(current, (enhanced) => {
if (promptRef.current) {
promptRef.current.value = enhanced;
setHasPrompt(true);
}
});
}, [enhancePrompt, promptRef, setHasPrompt]);
return { handleEnhancePrompt, isEnhancingPrompt };
}
function shouldDisableSubmit(
isCreating: boolean,
isSummarizing: boolean,
hasPrompt: boolean,
hasProfiles: boolean,
) {
const isBusy = Number(isCreating) + Number(isSummarizing);
const submitPenalty = Number(hasPrompt === false) + Number(hasProfiles === false) + isBusy;
return submitPenalty > 0;
}
function useEnforceCompatibleProfile(
hasExecutorProfile: boolean,
compatible: AgentProfileOption[],
@@ -194,6 +211,93 @@ function useEnforceCompatibleProfile(
}, [hasExecutorProfile, compatible, selectedId, setSelected]);
}
function useSessionProfileSelection({
agentProfiles,
executorProfile,
defaultProfileId,
selectedProfileId,
setSelectedProfileId,
}: {
agentProfiles: AgentProfileOption[];
executorProfile: ExecutorProfile | null;
defaultProfileId: string;
selectedProfileId: string;
setSelectedProfileId: (value: string) => void;
}) {
const compatibleAgentProfiles = useCompatibleAgentProfiles(agentProfiles, executorProfile);
useEnforceCompatibleProfile(
Boolean(executorProfile),
compatibleAgentProfiles,
selectedProfileId,
setSelectedProfileId,
);
const profileOptions = useAgentProfileOptions(compatibleAgentProfiles);
const hasProfiles = profileOptions.length > 0;
const noCompatibleProfiles = isMissingCompatibleProfile(
executorProfile,
agentProfiles.length,
hasProfiles,
);
const showAgentSelector =
hasProfiles &&
(profileOptions.length > 1 ||
(!!defaultProfileId && !profileOptions.find((o) => o.value === defaultProfileId)));
return {
profileOptions,
hasProfiles,
noCompatibleProfiles,
showAgentSelector,
};
}
function SessionFormHeader({
executorLabel,
worktreeBranch,
noCompatibleProfiles,
hasProfiles,
executorProfileName,
showAgentSelector,
profileOptions,
selectedProfileId,
isCreating,
onProfileChange,
}: {
executorLabel: string | null;
worktreeBranch: string | null;
noCompatibleProfiles: boolean;
hasProfiles: boolean;
executorProfileName: string | null;
showAgentSelector: boolean;
profileOptions: ReturnType<typeof useAgentProfileOptions>;
selectedProfileId: string;
isCreating: boolean;
onProfileChange: (value: string) => void;
}) {
return (
<>
<EnvironmentBadges executorLabel={executorLabel} worktreeBranch={worktreeBranch} />
<NoAgentBanner
noCompatibleProfiles={noCompatibleProfiles}
hasProfiles={hasProfiles}
executorProfileName={executorProfileName}
/>
{showAgentSelector && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Agent Profile</label>
<AgentSelector
options={profileOptions}
value={selectedProfileId}
onValueChange={onProfileChange}
disabled={isCreating}
placeholder="Select agent profile"
/>
</div>
)}
</>
);
}
// eslint-disable-next-line max-lines-per-function
function NewSessionForm({
taskId,
@@ -206,6 +310,7 @@ function NewSessionForm({
initialPrompt,
agentProfiles,
groupId,
handoff,
onClose,
}: {
taskId: string;
@@ -218,16 +323,22 @@ function NewSessionForm({
initialPrompt: string | null;
agentProfiles: AgentProfileOption[];
groupId?: string;
handoff?: HandoffPreset;
onClose: () => void;
}) {
const handoffInitial = handoff ? buildHandoffInitialState(handoff) : null;
const { toast } = useToast();
const setActiveSession = useAppStore((state) => state.setActiveSession);
const { summarize, isSummarizing } = useSummarizeSession();
const [isCreating, setIsCreating] = useState(false);
const [contextValue, setContextValue] = useState("blank");
const [selectedProfileId, setSelectedProfileId] = useState(initialProfileId ?? defaultProfileId);
const [contextValue, setContextValue] = useState(handoffInitial?.contextValue ?? "blank");
const [selectedProfileId, setSelectedProfileId] = useState(
handoffInitial?.selectedProfileId ?? initialProfileId ?? defaultProfileId,
);
const [hasPrompt, setHasPrompt] = useState(false);
const [isCreating, setIsCreating] = useState(false);
const promptRef = useRef<HTMLTextAreaElement>(null);
const busySignal = Number(isCreating) + Number(isSummarizing);
const isBusyState = busySignal > 0;
const {
attachments,
isDragging,
@@ -239,146 +350,71 @@ function NewSessionForm({
handleDrop,
handleAttachClick,
handleFileInputChange,
} = useDialogAttachments(isCreating || isSummarizing);
} = useDialogAttachments(isBusyState);
const contextItems = useMemo(
() => toContextItems(attachments, handleRemoveAttachment),
[attachments, handleRemoveAttachment],
);
const compatibleAgentProfiles = useCompatibleAgentProfiles(agentProfiles, executorProfile);
useEnforceCompatibleProfile(
Boolean(executorProfile),
compatibleAgentProfiles,
selectedProfileId,
setSelectedProfileId,
);
const profileOptions = useAgentProfileOptions(compatibleAgentProfiles);
const sessionOptions = useSessionOptions(taskId);
const isUtilityConfigured = useIsUtilityConfigured();
const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({ sessionId: null });
const handleEnhancePrompt = useCallback(() => {
const current = promptRef.current?.value?.trim();
if (!current) return;
enhancePrompt(current, (enhanced) => {
if (promptRef.current) {
promptRef.current.value = enhanced;
setHasPrompt(true);
}
});
}, [enhancePrompt]);
const hasProfiles = profileOptions.length > 0;
const noCompatibleProfiles = isMissingCompatibleProfile(
const profileSelection = useSessionProfileSelection({
agentProfiles,
executorProfile,
agentProfiles.length,
hasProfiles,
defaultProfileId,
selectedProfileId,
setSelectedProfileId,
});
const { handleEnhancePrompt, isEnhancingPrompt } = useSessionPromptEnhancer(
promptRef,
setHasPrompt,
);
const showAgentSelector =
hasProfiles &&
(profileOptions.length > 1 ||
(!!defaultProfileId && !profileOptions.find((o) => o.value === defaultProfileId)));
const isBusy = isCreating || isSummarizing;
const handleContextChange = useSessionContextChange({
promptRef,
initialPrompt,
summarize,
toast,
setContextValue,
setHasPrompt,
});
useHandoffAutoSummarize(handoff, handoffInitial?.contextValue ?? "blank", handleContextChange);
const handleContextChange = useCallback(
async (value: string) => {
if (!value) return;
setContextValue(value);
if (value === "copy_prompt" && initialPrompt && promptRef.current) {
promptRef.current.value = initialPrompt;
setHasPrompt(true);
} else if (value === "blank" && promptRef.current) {
promptRef.current.value = "";
setHasPrompt(false);
} else if (value.startsWith("summarize:")) {
const sessionId = value.slice("summarize:".length);
const result = await summarize(sessionId);
if (result && promptRef.current) {
promptRef.current.value = result;
setHasPrompt(true);
} else {
setContextValue("blank");
toast({
title: "Summarize failed",
description:
"Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.",
variant: "error",
});
}
}
},
[initialPrompt, summarize, toast],
);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const typed = promptRef.current?.value?.trim() ?? "";
const prompt =
contextValue === "copy_prompt" && !typed && initialPrompt ? initialPrompt : typed;
if (!prompt) return;
setIsCreating(true);
try {
const { request } = buildStartRequest(taskId, selectedProfileId, {
executorId,
prompt,
attachments: toMessageAttachments(attachments),
});
const response = await launchSession(request);
if (!response.session_id) {
throw new Error("Session created but no session ID returned");
}
const profile = agentProfiles.find((p: AgentProfileOption) => p.id === selectedProfileId);
activateNewSession(
response.session_id,
taskId,
profile?.label ?? "Agent",
groupId,
setActiveSession,
);
onClose();
} catch (error) {
toast({
title: "Failed to create session",
description: error instanceof Error ? error.message : "Unknown error",
variant: "error",
});
} finally {
setIsCreating(false);
}
},
[
taskId,
selectedProfileId,
executorId,
contextValue,
initialPrompt,
agentProfiles,
groupId,
onClose,
toast,
setActiveSession,
attachments,
],
const handleSubmit = useSessionLaunchSubmit({
promptRef,
taskId,
selectedProfileId,
executorId,
contextValue,
initialPrompt,
agentProfiles,
groupId,
attachments,
onClose,
toast,
setActiveSession,
activateSession: activateNewSession,
setIsCreating,
});
const isSubmitDisabled = shouldDisableSubmit(
isCreating,
isSummarizing,
hasPrompt,
profileSelection.hasProfiles,
);
return (
<form onSubmit={handleSubmit} className="space-y-4">
<EnvironmentBadges executorLabel={executorLabel} worktreeBranch={worktreeBranch} />
<NoAgentBanner
noCompatibleProfiles={noCompatibleProfiles}
hasProfiles={hasProfiles}
<SessionFormHeader
executorLabel={executorLabel}
worktreeBranch={worktreeBranch}
noCompatibleProfiles={profileSelection.noCompatibleProfiles}
hasProfiles={profileSelection.hasProfiles}
executorProfileName={executorProfile?.name ?? null}
showAgentSelector={profileSelection.showAgentSelector}
profileOptions={profileSelection.profileOptions}
selectedProfileId={selectedProfileId}
isCreating={isCreating}
onProfileChange={setSelectedProfileId}
/>
{showAgentSelector && (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Agent Profile</label>
<AgentSelector
options={profileOptions}
value={selectedProfileId}
onValueChange={setSelectedProfileId}
disabled={isCreating}
placeholder="Select agent profile"
/>
</div>
)}
<ContextSelect
value={contextValue}
onValueChange={handleContextChange}
@@ -386,66 +422,27 @@ function NewSessionForm({
sessionOptions={sessionOptions}
isSummarizing={isSummarizing}
/>
<div
className="relative"
<SessionPromptField
promptRef={promptRef}
contextItems={contextItems}
isBusy={isBusyState}
isDragging={isDragging}
isSummarizing={isSummarizing}
hasPrompt={hasPrompt}
hasProfiles={profileSelection.hasProfiles}
isUtilityConfigured={isUtilityConfigured}
isEnhancingPrompt={isEnhancingPrompt}
fileInputRef={fileInputRef}
onPromptInput={() => setHasPrompt(!!promptRef.current?.value?.trim())}
onPaste={handlePaste}
onSubmit={handleSubmit}
onAttachClick={handleAttachClick}
onEnhancePrompt={handleEnhancePrompt}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="rounded-md border border-input bg-transparent">
<ContextZone items={contextItems} />
<Textarea
ref={promptRef}
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={isBusy}
onInput={(e) => setHasPrompt(!!e.currentTarget.value)}
onPaste={handlePaste}
onKeyDown={(e) => {
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={isBusy} />
<EnhancePromptButton
onClick={handleEnhancePrompt}
isLoading={isEnhancingPrompt}
isConfigured={isUtilityConfigured}
/>
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={handleFileInputChange}
tabIndex={-1}
/>
</div>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 border-2 border-dashed border-primary rounded-md pointer-events-none">
<span className="text-sm text-primary font-medium">Drop files here</span>
</div>
)}
{isSummarizing && (
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-background/80">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<IconLoader2 className="h-4 w-4 animate-spin" />
<span>Generating summary...</span>
</div>
</div>
)}
</div>
onFileInputChange={handleFileInputChange}
/>
<DialogFooter>
<Button
type="button"
@@ -456,11 +453,7 @@ function NewSessionForm({
>
Cancel
</Button>
<Button
type="submit"
disabled={isBusy || !hasPrompt || !hasProfiles}
className="cursor-pointer"
>
<Button type="submit" disabled={isSubmitDisabled} className="cursor-pointer">
{isCreating ? "Creating..." : "Start Agent"}
</Button>
</DialogFooter>
@@ -496,7 +489,23 @@ function NoAgentBanner({
return null;
}
export function NewSessionDialog({ open, onOpenChange, taskId, groupId }: NewSessionDialogProps) {
function handoffProfileLabel(
agentProfiles: AgentProfileOption[],
handoff: HandoffPreset | undefined,
): string | null {
if (!handoff) return null;
const profile = agentProfiles.find((p) => p.id === handoff.targetProfileId);
if (!profile) return null;
return agentProfileDisplayLabel(profile);
}
export function NewSessionDialog({
open,
onOpenChange,
taskId,
groupId,
handoff,
}: NewSessionDialogProps) {
const {
taskTitle,
agentProfiles,
@@ -508,17 +517,29 @@ export function NewSessionDialog({ open, onOpenChange, taskId, groupId }: NewSes
effectiveDefaultProfileId,
} = useNewSessionDialogState(taskId);
const executorProfile = useTaskExecutorProfile(taskId, open);
const handoffLabel = handoffProfileLabel(agentProfiles, handoff);
const formKey = handoff
? `${open}-${handoff.sourceSessionId}-${handoff.targetProfileId}`
: `${open}`;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[520px]">
<DialogHeader>
<DialogTitle className="text-sm font-medium">
New agent in <span className="text-foreground">{taskTitle}</span>
{handoffLabel ? (
<>
Hand off to <span className="text-foreground">{handoffLabel}</span>
</>
) : (
<>
New agent in <span className="text-foreground">{taskTitle}</span>
</>
)}
</DialogTitle>
</DialogHeader>
<NewSessionForm
key={`${open}`}
key={formKey}
taskId={taskId}
defaultProfileId={sessionProfileId}
initialProfileId={effectiveDefaultProfileId}
@@ -529,6 +550,7 @@ export function NewSessionDialog({ open, onOpenChange, taskId, groupId }: NewSes
initialPrompt={initialPrompt}
agentProfiles={agentProfiles}
groupId={groupId}
handoff={handoff}
onClose={() => onOpenChange(false)}
/>
</DialogContent>
@@ -0,0 +1,413 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { FormEvent } from "react";
import type { AgentProfileOption } from "@/lib/state/slices";
import type { FileAttachment } from "./chat/file-attachment";
const mockLaunchSession = vi.fn();
const mockBuildStartRequest = vi.fn();
const mockToMessageAttachments = vi.fn();
vi.mock("@/lib/services/session-launch-service", () => ({
launchSession: (...args: Parameters<typeof mockLaunchSession>) => mockLaunchSession(...args),
}));
vi.mock("@/lib/services/session-launch-helpers", () => ({
buildStartRequest: (...args: Parameters<typeof mockBuildStartRequest>) =>
mockBuildStartRequest(...args),
}));
vi.mock("@/components/task-create-dialog-helpers", () => ({
toMessageAttachments: (...args: Parameters<typeof mockToMessageAttachments>) =>
mockToMessageAttachments(...args),
}));
import { useSessionContextChange, useSessionLaunchSubmit } from "./new-session-form-actions";
const mockToast = vi.fn();
const AGENT_PROFILE_A: AgentProfileOption = {
id: "profile-a",
label: "Profile A",
agent_name: "agent-a",
agent_id: "agent-id",
cli_passthrough: false,
};
const ATTACHMENT: FileAttachment = {
id: "attachment-1",
data: "dGVzdA==",
mimeType: "text/plain",
fileName: "notes.txt",
size: 42,
isImage: false,
};
const TASK_ID = "task-1";
const PROFILE_ID = "profile-a";
const EXECUTOR_ID = "executor-1";
const SESSION_ID = "session-2";
const GROUP_ID = "group-1";
const SEED_PROMPT = "seed prompt";
const MESSAGE_ATTACHMENTS = ["message-attachment"];
const SUMMARY_ACTION = "summarize:session-1";
const SUMMARY_SESSION_ID = "session-1";
// eslint-disable-next-line max-lines-per-function
describe("useSessionContextChange", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("copies the initial prompt when copy_prompt is selected", async () => {
const promptRef = { current: { value: "original" } as unknown as HTMLTextAreaElement };
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const { result } = renderHook(() =>
useSessionContextChange({
promptRef,
initialPrompt: "starter",
summarize: vi.fn(),
toast: mockToast,
setContextValue,
setHasPrompt,
}),
);
await act(async () => {
await result.current("copy_prompt");
});
expect(promptRef.current.value).toBe("starter");
expect(setContextValue).toHaveBeenCalledWith("copy_prompt");
expect(setHasPrompt).toHaveBeenCalledWith(true);
expect(mockToast).not.toHaveBeenCalled();
});
it("clears the prompt for blank", async () => {
const promptRef = { current: { value: "original" } as unknown as HTMLTextAreaElement };
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const { result } = renderHook(() =>
useSessionContextChange({
promptRef,
initialPrompt: null,
summarize: vi.fn(),
toast: mockToast,
setContextValue,
setHasPrompt,
}),
);
await act(async () => {
await result.current("blank");
});
expect(promptRef.current.value).toBe("");
expect(setContextValue).toHaveBeenCalledWith("blank");
expect(setHasPrompt).toHaveBeenCalledWith(false);
});
it("summarizes sessions into prompt text", async () => {
const promptRef = { current: { value: "" } as unknown as HTMLTextAreaElement };
const summarize = vi.fn().mockResolvedValue("summary text");
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const { result } = renderHook(() =>
useSessionContextChange({
promptRef,
initialPrompt: null,
summarize,
toast: mockToast,
setContextValue,
setHasPrompt,
}),
);
await act(async () => {
await result.current(SUMMARY_ACTION);
});
expect(summarize).toHaveBeenCalledWith(SUMMARY_SESSION_ID);
expect(promptRef.current.value).toBe("summary text");
expect(setHasPrompt).toHaveBeenCalledWith(true);
expect(mockToast).not.toHaveBeenCalled();
});
it("handles summarize failures by clearing context and showing toast", async () => {
const promptRef = { current: { value: "" } as unknown as HTMLTextAreaElement };
const summarize = vi.fn().mockResolvedValue(null);
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const { result } = renderHook(() =>
useSessionContextChange({
promptRef,
initialPrompt: null,
summarize,
toast: mockToast,
setContextValue,
setHasPrompt,
}),
);
await act(async () => {
await result.current(SUMMARY_ACTION);
});
expect(setContextValue).toHaveBeenCalledWith("blank");
expect(mockToast).toHaveBeenCalledWith({
title: "Summarize failed",
description:
"Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.",
variant: "error",
});
expect(setHasPrompt).not.toHaveBeenCalled();
});
it("does not toast when summarize succeeds after prompt ref unmounts", async () => {
const promptRef = { current: null as HTMLTextAreaElement | null };
const summarize = vi.fn().mockResolvedValue("summary text");
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const { result } = renderHook(() =>
useSessionContextChange({
promptRef,
initialPrompt: null,
summarize,
toast: mockToast,
setContextValue,
setHasPrompt,
}),
);
await act(async () => {
await result.current(SUMMARY_ACTION);
});
expect(summarize).toHaveBeenCalledWith(SUMMARY_SESSION_ID);
expect(setContextValue).toHaveBeenCalledWith(SUMMARY_ACTION);
expect(setHasPrompt).not.toHaveBeenCalled();
expect(mockToast).not.toHaveBeenCalled();
});
it("sanitizes unsafe characters from summarize result before setting prompt", async () => {
const promptRef = { current: { value: "" } as unknown as HTMLTextAreaElement };
const summarize = vi.fn().mockResolvedValue("line1\r\n<unsafe>\nline2");
const setContextValue = vi.fn();
const setHasPrompt = vi.fn();
const { result } = renderHook(() =>
useSessionContextChange({
promptRef,
initialPrompt: null,
summarize,
toast: mockToast,
setContextValue,
setHasPrompt,
}),
);
await act(async () => {
await result.current(SUMMARY_ACTION);
});
expect(promptRef.current.value).toBe("line1\n unsafe \nline2");
expect(setHasPrompt).toHaveBeenCalledWith(true);
});
});
// eslint-disable-next-line max-lines-per-function
describe("useSessionLaunchSubmit", () => {
beforeEach(() => {
vi.clearAllMocks();
mockBuildStartRequest.mockReturnValue({
request: {
task_id: TASK_ID,
profile_id: PROFILE_ID,
executor_id: EXECUTOR_ID,
},
});
mockToMessageAttachments.mockReturnValue(MESSAGE_ATTACHMENTS);
mockLaunchSession.mockResolvedValue({ session_id: SESSION_ID });
});
it("creates a session and activates it with the typed prompt", async () => {
const promptRef = { current: { value: " hello " } as unknown as HTMLTextAreaElement };
const mockSetActiveSession = vi.fn();
const mockActivateSession = vi.fn();
const mockSetIsCreating = vi.fn();
const mockOnClose = vi.fn();
const { result } = renderHook(() =>
useSessionLaunchSubmit({
promptRef,
taskId: TASK_ID,
selectedProfileId: PROFILE_ID,
executorId: EXECUTOR_ID,
contextValue: "blank",
initialPrompt: null,
agentProfiles: [AGENT_PROFILE_A],
attachments: [ATTACHMENT],
groupId: GROUP_ID,
onClose: mockOnClose,
toast: mockToast,
setActiveSession: mockSetActiveSession,
activateSession: mockActivateSession,
setIsCreating: mockSetIsCreating,
}),
);
const event = {
preventDefault: vi.fn(),
} as unknown as FormEvent;
await act(async () => {
await result.current(event);
});
expect(mockBuildStartRequest).toHaveBeenCalledWith(TASK_ID, PROFILE_ID, {
executorId: EXECUTOR_ID,
prompt: "hello",
attachments: MESSAGE_ATTACHMENTS,
});
expect(mockLaunchSession).toHaveBeenCalledWith({
task_id: TASK_ID,
profile_id: PROFILE_ID,
executor_id: EXECUTOR_ID,
});
expect(mockActivateSession).toHaveBeenCalledWith(
SESSION_ID,
TASK_ID,
"Profile A",
GROUP_ID,
mockSetActiveSession,
);
expect(mockOnClose).toHaveBeenCalled();
expect(mockSetIsCreating).toHaveBeenNthCalledWith(1, true);
expect(mockSetIsCreating).toHaveBeenLastCalledWith(false);
});
it("uses initial prompt when context is copy_prompt and user did not type anything", async () => {
const promptRef = { current: { value: " " } as unknown as HTMLTextAreaElement };
const mockSetActiveSession = vi.fn();
const mockActivateSession = vi.fn();
const mockSetIsCreating = vi.fn();
const mockOnClose = vi.fn();
const { result } = renderHook(() =>
useSessionLaunchSubmit({
promptRef,
taskId: TASK_ID,
selectedProfileId: PROFILE_ID,
executorId: EXECUTOR_ID,
contextValue: "copy_prompt",
initialPrompt: SEED_PROMPT,
agentProfiles: [AGENT_PROFILE_A],
attachments: [ATTACHMENT],
onClose: mockOnClose,
toast: mockToast,
setActiveSession: mockSetActiveSession,
activateSession: mockActivateSession,
setIsCreating: mockSetIsCreating,
}),
);
const event = {
preventDefault: vi.fn(),
} as unknown as FormEvent;
await act(async () => {
await result.current(event);
});
expect(mockBuildStartRequest).toHaveBeenCalledWith(
TASK_ID,
PROFILE_ID,
expect.objectContaining({ prompt: SEED_PROMPT }),
);
expect(mockActivateSession).toHaveBeenCalledWith(
SESSION_ID,
TASK_ID,
"Profile A",
undefined,
mockSetActiveSession,
);
expect(mockOnClose).toHaveBeenCalled();
});
it("does not call launch when prompt is empty", async () => {
const promptRef = { current: { value: " " } as unknown as HTMLTextAreaElement };
const mockSetActiveSession = vi.fn();
const mockActivateSession = vi.fn();
const mockSetIsCreating = vi.fn();
const mockOnClose = vi.fn();
const { result } = renderHook(() =>
useSessionLaunchSubmit({
promptRef,
taskId: TASK_ID,
selectedProfileId: PROFILE_ID,
executorId: EXECUTOR_ID,
contextValue: "blank",
initialPrompt: null,
agentProfiles: [AGENT_PROFILE_A],
attachments: [ATTACHMENT],
onClose: mockOnClose,
toast: mockToast,
setActiveSession: mockSetActiveSession,
activateSession: mockActivateSession,
setIsCreating: mockSetIsCreating,
}),
);
const event = {
preventDefault: vi.fn(),
} as unknown as FormEvent;
await act(async () => {
await result.current(event);
});
expect(mockBuildStartRequest).not.toHaveBeenCalled();
expect(mockLaunchSession).not.toHaveBeenCalled();
expect(mockSetIsCreating).not.toHaveBeenCalled();
expect(mockOnClose).not.toHaveBeenCalled();
});
it("shows a toast when launching fails", async () => {
mockLaunchSession.mockRejectedValueOnce(new Error("launch failed"));
const promptRef = { current: { value: "hello" } as unknown as HTMLTextAreaElement };
const mockSetActiveSession = vi.fn();
const mockActivateSession = vi.fn();
const mockSetIsCreating = vi.fn();
const mockOnClose = vi.fn();
const { result } = renderHook(() =>
useSessionLaunchSubmit({
promptRef,
taskId: TASK_ID,
selectedProfileId: PROFILE_ID,
executorId: EXECUTOR_ID,
contextValue: "blank",
initialPrompt: null,
agentProfiles: [AGENT_PROFILE_A],
attachments: [ATTACHMENT],
onClose: mockOnClose,
toast: mockToast,
setActiveSession: mockSetActiveSession,
activateSession: mockActivateSession,
setIsCreating: mockSetIsCreating,
}),
);
const event = {
preventDefault: vi.fn(),
} as unknown as FormEvent;
await act(async () => {
await result.current(event);
});
expect(mockToast).toHaveBeenCalledWith({
title: "Failed to create session",
description: "launch failed",
variant: "error",
});
expect(mockActivateSession).not.toHaveBeenCalled();
expect(mockSetIsCreating).toHaveBeenLastCalledWith(false);
});
});
@@ -0,0 +1,153 @@
import { useCallback, type RefObject } from "react";
import { launchSession } from "@/lib/services/session-launch-service";
import { buildStartRequest } from "@/lib/services/session-launch-helpers";
import { toMessageAttachments } from "@/components/task-create-dialog-helpers";
import type { FileAttachment } from "./chat/file-attachment";
import type { AgentProfileOption } from "@/lib/state/slices";
type ToastFn = (opts: {
title: string;
description?: string;
variant?: "error" | "default";
}) => void;
type SessionContextChangeOpts = {
promptRef: RefObject<HTMLTextAreaElement | null>;
initialPrompt: string | null;
summarize: (sessionId: string) => Promise<string | null>;
toast: ToastFn;
setContextValue: (v: string) => void;
setHasPrompt: (v: boolean) => void;
};
function sanitizePromptText(value: string): string {
return value.replace(/\r/g, "").replace(/[<>]/g, " ");
}
export function useSessionContextChange(opts: SessionContextChangeOpts) {
const { promptRef, initialPrompt, summarize, toast, setContextValue, setHasPrompt } = opts;
return useCallback(
async (value: string) => {
if (!value) return;
setContextValue(value);
if (value === "copy_prompt" && initialPrompt && promptRef.current) {
promptRef.current.value = initialPrompt;
setHasPrompt(true);
} else if (value === "blank" && promptRef.current) {
promptRef.current.value = "";
setHasPrompt(false);
} else if (value.startsWith("summarize:")) {
const sessionId = value.slice("summarize:".length);
const result = await summarize(sessionId);
if (result === null) {
setContextValue("blank");
toast({
title: "Summarize failed",
description:
"Could not generate a summary. Check that the summarize utility agent is configured and enabled in settings.",
variant: "error",
});
} else if (promptRef.current) {
promptRef.current.value = sanitizePromptText(result);
setHasPrompt(true);
}
}
},
[initialPrompt, promptRef, summarize, setContextValue, setHasPrompt, toast],
);
}
export function useSessionLaunchSubmit({
promptRef,
taskId,
selectedProfileId,
executorId,
contextValue,
initialPrompt,
agentProfiles,
groupId,
attachments,
onClose,
toast,
setActiveSession,
activateSession,
setIsCreating,
}: {
promptRef: RefObject<HTMLTextAreaElement | null>;
taskId: string;
selectedProfileId: string;
executorId: string;
contextValue: string;
initialPrompt: string | null;
agentProfiles: AgentProfileOption[];
groupId?: string;
attachments: FileAttachment[];
onClose: () => void;
toast: ToastFn;
setActiveSession: (taskId: string, sessionId: string) => void;
activateSession: (
sessionId: string,
taskId: string,
tabLabel: string,
groupId: string | undefined,
setActiveSession: (taskId: string, sessionId: string) => void,
) => void;
setIsCreating: (creating: boolean) => void;
}) {
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
const typed = promptRef.current?.value?.trim() ?? "";
const prompt =
contextValue === "copy_prompt" && !typed && initialPrompt ? initialPrompt : typed;
if (!prompt) return;
setIsCreating(true);
try {
const { request } = buildStartRequest(taskId, selectedProfileId, {
executorId,
prompt,
attachments: toMessageAttachments(attachments),
});
const response = await launchSession(request);
if (!response.session_id) {
throw new Error("Session created but no session ID returned");
}
const profile = agentProfiles.find((p) => p.id === selectedProfileId);
activateSession(
response.session_id,
taskId,
profile?.label ?? "Agent",
groupId,
setActiveSession,
);
onClose();
} catch (error) {
toast({
title: "Failed to create session",
description: error instanceof Error ? error.message : "Unknown error",
variant: "error",
});
} finally {
setIsCreating(false);
}
},
[
promptRef,
taskId,
selectedProfileId,
executorId,
contextValue,
initialPrompt,
agentProfiles,
groupId,
onClose,
toast,
setActiveSession,
attachments,
activateSession,
setIsCreating,
],
);
return handleSubmit;
}
@@ -0,0 +1,111 @@
"use client";
import type { RefObject } from "react";
import { Textarea } from "@kandev/ui/textarea";
import { IconLoader2 } from "@tabler/icons-react";
import { EnhancePromptButton } from "@/components/enhance-prompt-button";
import { ContextZone } from "./chat/context-items/context-zone";
import { AttachButton } from "./session-dialog-shared";
import type { ContextItem } from "@/lib/types/context";
type SessionPromptFieldProps = {
promptRef: RefObject<HTMLTextAreaElement | null>;
contextItems: ContextItem[];
isBusy: boolean;
isDragging: boolean;
isSummarizing: boolean;
hasPrompt: boolean;
hasProfiles: boolean;
isUtilityConfigured: boolean;
isEnhancingPrompt: boolean;
fileInputRef: RefObject<HTMLInputElement | null>;
onPromptInput: () => void;
onPaste: (e: React.ClipboardEvent<HTMLTextAreaElement>) => void;
onSubmit: (e: React.FormEvent) => void;
onAttachClick: () => void;
onEnhancePrompt: () => void;
onDragOver: (e: React.DragEvent) => void;
onDragLeave: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void;
onFileInputChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
};
export function SessionPromptField({
promptRef,
contextItems,
isBusy,
isDragging,
isSummarizing,
hasPrompt,
hasProfiles,
isUtilityConfigured,
isEnhancingPrompt,
fileInputRef,
onPromptInput,
onPaste,
onSubmit,
onAttachClick,
onEnhancePrompt,
onDragOver,
onDragLeave,
onDrop,
onFileInputChange,
}: SessionPromptFieldProps) {
return (
<div className="relative" onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop}>
<div className="rounded-md border border-input bg-transparent">
<ContextZone items={contextItems} />
<Textarea
ref={promptRef}
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={isBusy}
onInput={onPromptInput}
onPaste={onPaste}
onKeyDown={(e) => {
if (
e.key === "Enter" &&
(e.metaKey || e.ctrlKey) &&
!isBusy &&
hasPrompt &&
hasProfiles
) {
e.preventDefault();
onSubmit(e);
}
}}
/>
<div className="flex items-center px-1 pb-1">
<AttachButton onClick={onAttachClick} disabled={isBusy} />
<EnhancePromptButton
onClick={onEnhancePrompt}
isLoading={isEnhancingPrompt}
isConfigured={isUtilityConfigured}
/>
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={onFileInputChange}
tabIndex={-1}
/>
</div>
{isDragging && (
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 border-2 border-dashed border-primary rounded-md pointer-events-none">
<span className="text-sm text-primary font-medium">Drop files here</span>
</div>
)}
{isSummarizing && (
<div className="absolute inset-0 flex items-center justify-center rounded-md bg-background/80">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<IconLoader2 className="h-4 w-4 animate-spin" />
<span>Generating summary...</span>
</div>
</div>
)}
</div>
);
}
+39
View File
@@ -31,6 +31,8 @@ import {
} from "@/hooks/domains/session/use-session-actions";
import { shareableSessionStateClient } from "@/components/task/share/share-button";
import { ShareDialog } from "@/components/task/share/share-dialog";
import { HandoffContextMenuSub } from "@/components/task/handoff-profile-menu-items";
import { NewSessionDialog, type HandoffPreset } from "@/components/task/new-session-dialog";
import type { TaskSessionState } from "@/lib/types/http";
import { isSessionActive } from "./session-sort";
import { useTabMaximizeOnDoubleClick } from "./use-tab-maximize";
@@ -166,16 +168,22 @@ function SessionContextMenuItems({
sessionState,
isPrimary,
canShare,
taskId,
sessionId,
actions,
onDelete,
onShare,
onHandoffProfile,
}: {
sessionState: TaskSessionState | null;
isPrimary: boolean;
canShare: boolean;
taskId: string | null;
sessionId: string | undefined;
actions: ReturnType<typeof useSessionTabActions>;
onDelete: () => void;
onShare: () => void;
onHandoffProfile: (profileId: string) => void;
}) {
return (
<ContextMenuContent>
@@ -210,6 +218,12 @@ function SessionContextMenuItems({
</ContextMenuItem>
</>
)}
{taskId && sessionId && (
<>
<ContextMenuSeparator />
<HandoffContextMenuSub taskId={taskId} onSelectProfile={onHandoffProfile} />
</>
)}
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer" onSelect={actions.handleCloseOthers}>
Close Others
@@ -295,8 +309,18 @@ export function SessionTab(props: IDockviewPanelHeaderProps) {
const onDoubleClick = useTabMaximizeOnDoubleClick(api);
const [confirmDelete, setConfirmDelete] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [handoffOpen, setHandoffOpen] = useState(false);
const [handoffPreset, setHandoffPreset] = useState<HandoffPreset | null>(null);
const [isActive, setIsActive] = useState(api.isActive);
const canShare = !!taskId && !!sessionId && shareableSessionStateClient(sessionState);
const handleHandoffProfile = useCallback(
(profileId: string) => {
if (!sessionId) return;
setHandoffPreset({ sourceSessionId: sessionId, targetProfileId: profileId });
setHandoffOpen(true);
},
[sessionId],
);
useEffect(() => {
const disposable = api.onDidActiveChange((e) => setIsActive(e.isActive));
@@ -340,9 +364,12 @@ export function SessionTab(props: IDockviewPanelHeaderProps) {
sessionState={sessionState}
isPrimary={isPrimary}
canShare={canShare}
taskId={taskId}
sessionId={sessionId}
actions={actions}
onDelete={() => setConfirmDelete(true)}
onShare={() => setShareOpen(true)}
onHandoffProfile={handleHandoffProfile}
/>
</ContextMenu>
<DeleteSessionDialog
@@ -360,6 +387,18 @@ export function SessionTab(props: IDockviewPanelHeaderProps) {
sessionId={sessionId}
/>
)}
{taskId && handoffPreset && (
<NewSessionDialog
open={handoffOpen}
onOpenChange={(open) => {
setHandoffOpen(open);
if (!open) setHandoffPreset(null);
}}
taskId={taskId}
groupId={api.group?.id}
handoff={handoffPreset}
/>
)}
</>
);
}
+42 -6
View File
@@ -1024,24 +1024,60 @@ export class SessionPage {
await this.newSessionMenuButton().click();
}
/** New session or handoff dialog container. */
sessionLaunchDialog(): Locator {
return this.page.getByRole("dialog").filter({ hasText: /New agent in|Hand off to/ });
}
/** The new session dialog container. */
newSessionDialog(): Locator {
return this.page.getByRole("dialog").filter({ hasText: "New agent in" });
}
/** Prompt textarea inside the new session dialog. */
newSessionPromptInput(): Locator {
return this.newSessionDialog().locator("textarea");
/** Handoff dialog opened from session tab context menu. */
handoffDialog(): Locator {
return this.page.getByRole("dialog").filter({ hasText: "Hand off to" });
}
/** Start Agent button inside the new session dialog. */
/** Prompt textarea inside the new session or handoff dialog. */
newSessionPromptInput(): Locator {
return this.sessionLaunchDialog().locator("textarea");
}
/** Start Agent button inside the new session or handoff dialog. */
newSessionStartButton(): Locator {
return this.newSessionDialog().getByRole("button", { name: "Start Agent" });
return this.sessionLaunchDialog().getByRole("button", { name: "Start Agent" });
}
/** Environment info badges inside the new session dialog. */
newSessionEnvironmentInfo(): Locator {
return this.newSessionDialog().getByText("Same environment as current session");
return this.sessionLaunchDialog().getByText("Same environment as current session");
}
/** Handoff submenu trigger in session context or actions menu. */
handoffSubmenu(): Locator {
return this.page.getByTestId("session-handoff-submenu");
}
/** Handoff profile item in the Handoff submenu. */
handoffProfileItem(profileId: string): Locator {
return this.page.getByTestId(`handoff-profile-${profileId}`);
}
/** Open handoff dialog via session tab right-click context menu. */
async openHandoffDialog(sessionId: string, profileId: string): Promise<void> {
await this.sessionTabBySessionId(sessionId).click({ button: "right" });
await this.handoffSubmenu().hover();
await this.handoffProfileItem(profileId).click();
}
/** Open handoff dialog via mobile session row actions menu. */
async openMobileHandoffDialog(sessionId: string, profileId: string): Promise<void> {
await this.page.getByTestId("mobile-sessions-pill").click();
const row = this.page.getByTestId(`mobile-session-row-${sessionId}`);
await row.getByRole("button", { name: "Session actions" }).click();
await this.handoffSubmenu().hover();
await this.handoffProfileItem(profileId).click();
}
/** Session tab in dockview by session label (e.g., "Session 1", "Session 2"). */
@@ -0,0 +1,66 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
async function createProfiles(
apiClient: InstanceType<typeof import("../../helpers/api-client").ApiClient>,
) {
const { agents } = await apiClient.listAgents();
if (agents.length === 0) throw new Error("no agents available in test fixtures");
const agentId = agents[0].id;
const profileA = await apiClient.createAgentProfile(agentId, "Mobile Handoff A", {
model: "mock-fast",
});
const profileB = await apiClient.createAgentProfile(agentId, "Mobile Handoff B", {
model: "mock-slow",
});
return { profileA, profileB };
}
test.describe("Session handoff on mobile", () => {
test("opens handoff dialog from mobile session actions menu", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { profileA, profileB } = await createProfiles(apiClient);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Mobile Handoff Task",
profileA.id,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000 },
)
.toBe(true);
const { sessions } = await apiClient.listTaskSessions(task.id);
const session1Id = sessions[0].id;
await testPage.goto(`/t/${task.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await session.openMobileHandoffDialog(session1Id, profileB.id);
await expect(session.handoffDialog()).toBeVisible({ timeout: 5_000 });
await expect(session.handoffDialog()).toContainText("Hand off to");
await expect(session.handoffDialog()).toContainText("Mobile Handoff B");
});
});
@@ -0,0 +1,107 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
const DONE_STATES = ["COMPLETED", "WAITING_FOR_INPUT"];
const HANDOFF_SUMMARY = "Handoff summary: completed the prior session work.";
async function mockSummarizeUtility(testPage: import("@playwright/test").Page) {
await testPage.route("**/api/v1/utility/execute", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
response: HANDOFF_SUMMARY,
}),
});
});
}
async function createProfiles(
apiClient: InstanceType<typeof import("../../helpers/api-client").ApiClient>,
) {
const { agents } = await apiClient.listAgents();
if (agents.length === 0) throw new Error("no agents available in test fixtures");
const agentId = agents[0].id;
const profileA = await apiClient.createAgentProfile(agentId, "Handoff Profile A", {
model: "mock-fast",
});
const profileB = await apiClient.createAgentProfile(agentId, "Handoff Profile B", {
model: "mock-slow",
});
return { profileA, profileB };
}
test.describe("Session handoff", () => {
test("opens handoff dialog with target profile from session tab context menu", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { profileA, profileB } = await createProfiles(apiClient);
await mockSummarizeUtility(testPage);
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"Session Handoff Task",
profileA.id,
{
description: "/e2e:simple-message",
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
},
);
await expect
.poll(
async () => {
const { sessions } = await apiClient.listTaskSessions(task.id);
return DONE_STATES.includes(sessions[0]?.state ?? "");
},
{ timeout: 30_000, message: "Waiting for first session to finish" },
)
.toBe(true);
const { sessions } = await apiClient.listTaskSessions(task.id);
const session1Id = sessions[0].id;
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.taskCardByTitle("Session Handoff Task").click();
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.chat.getByText("simple mock response", { exact: false })).toBeVisible({
timeout: 15_000,
});
await session.openHandoffDialog(session1Id, profileB.id);
await expect(session.handoffDialog()).toBeVisible({ timeout: 5_000 });
await expect(session.handoffDialog()).toContainText("Hand off to");
await expect(session.handoffDialog()).toContainText("Handoff Profile B");
const prompt = session.newSessionPromptInput();
await expect(prompt).toHaveValue(HANDOFF_SUMMARY, { timeout: 15_000 });
await prompt.fill(`${HANDOFF_SUMMARY}\n/e2e:simple-message`);
await session.newSessionStartButton().click();
await expect(session.handoffDialog()).not.toBeVisible({ timeout: 15_000 });
await expect
.poll(
async () => {
const { sessions: updated } = await apiClient.listTaskSessions(task.id);
return updated.length;
},
{ timeout: 30_000, message: "Waiting for handoff session to be created" },
)
.toBe(2);
});
});
@@ -0,0 +1,157 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Executor } from "@/lib/types/http";
import { useTaskExecutorProfile } from "./use-task-executor-profile";
const mockFetchTaskEnvironment = vi.fn();
const EXECUTOR_ID = "executor-1";
const EXECUTOR_NAME = "Local Exec";
const PROFILE_ID = "profile-1";
const PROFILE_NAME = "Profile 1";
const TASK_ID = "task-1";
const TASK_ENV_ID = "env-1";
const REPO_ID = "repo-1";
const AGENT_EXEC_ID = "agent-exec-1";
const CONTROL_PORT = 8080;
const TIMESTAMP = "2026-01-01T00:00:00Z";
const PROFILE_TEMPLATE = {
id: PROFILE_ID,
executor_id: EXECUTOR_ID,
name: PROFILE_NAME,
prepare_script: "",
cleanup_script: "",
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
};
const EXECUTOR_TEMPLATE = {
id: EXECUTOR_ID,
name: EXECUTOR_NAME,
type: "local_pc",
status: "idle",
is_system: false,
profiles: [
{
...PROFILE_TEMPLATE,
executor_type: "local_pc",
executor_name: EXECUTOR_NAME,
},
],
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
} satisfies Executor;
let mockExecutors: Executor[] = [
{
...EXECUTOR_TEMPLATE,
},
];
const BASE_ENV = {
id: TASK_ENV_ID,
task_id: TASK_ID,
repository_id: REPO_ID,
executor_type: "local_pc",
executor_id: EXECUTOR_ID,
executor_profile_id: PROFILE_ID,
agent_execution_id: AGENT_EXEC_ID,
control_port: CONTROL_PORT,
status: "ready",
created_at: TIMESTAMP,
updated_at: TIMESTAMP,
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: { executors: { items: Executor[] } }) => unknown) =>
selector({
executors: { items: mockExecutors },
}),
}));
vi.mock("@/lib/api/domains/task-environment-api", () => ({
fetchTaskEnvironment: (...args: Parameters<typeof mockFetchTaskEnvironment>) =>
mockFetchTaskEnvironment(...args),
}));
describe("useTaskExecutorProfile", () => {
beforeEach(() => {
vi.clearAllMocks();
mockFetchTaskEnvironment.mockReset();
mockFetchTaskEnvironment.mockResolvedValue(BASE_ENV);
mockExecutors = [
{
...EXECUTOR_TEMPLATE,
},
];
});
it("resolves matching executor profile and fills fallback executor metadata", async () => {
const { result } = renderHook(() => useTaskExecutorProfile(TASK_ID));
await waitFor(() => {
expect(mockFetchTaskEnvironment).toHaveBeenCalledWith(TASK_ID);
});
await act(async () => {
await Promise.resolve();
});
expect(result.current).toMatchObject({
id: PROFILE_ID,
name: PROFILE_NAME,
executor_type: "local_pc",
executor_name: EXECUTOR_NAME,
});
});
it("returns null when environment profile cannot be found", async () => {
mockFetchTaskEnvironment.mockResolvedValue({
...BASE_ENV,
executor_profile_id: "missing-profile",
});
const { result } = renderHook(() => useTaskExecutorProfile(TASK_ID));
await act(async () => {
await Promise.resolve();
});
await waitFor(() => {
expect(result.current).toBeNull();
});
});
it("returns null when disabled", async () => {
const { result } = renderHook(() => useTaskExecutorProfile(TASK_ID, false));
expect(mockFetchTaskEnvironment).not.toHaveBeenCalled();
expect(result.current).toBeNull();
});
it("returns null when task ID is missing", async () => {
const { result } = renderHook(() => useTaskExecutorProfile(""));
expect(mockFetchTaskEnvironment).not.toHaveBeenCalled();
expect(result.current).toBeNull();
});
it("does not re-fetch when only unrelated executor metadata changes", async () => {
const { rerender } = renderHook(() => useTaskExecutorProfile(TASK_ID));
await waitFor(() => {
expect(mockFetchTaskEnvironment).toHaveBeenCalledTimes(1);
});
mockExecutors = [
{
...mockExecutors[0],
status: "running",
},
];
rerender();
await act(async () => {
await Promise.resolve();
});
expect(mockFetchTaskEnvironment).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,53 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useAppStore } from "@/components/state-provider";
import { fetchTaskEnvironment } from "@/lib/api/domains/task-environment-api";
import type { ExecutorProfile } from "@/lib/types/http";
/** Resolves the executor profile bound to a task's environment. */
export function useTaskExecutorProfile(taskId: string, enabled = true): ExecutorProfile | null {
const executors = useAppStore((state) => state.executors.items);
const executorsFingerprint = useMemo(
() =>
executors
.map(
(executor) =>
`${executor.id}|${executor.type}|${(executor.profiles ?? []).map((profile) => profile.id).join(",")}`,
)
.join(";"),
[executors],
);
const executorsRef = useRef(executors);
const [profile, setProfile] = useState<ExecutorProfile | null>(null);
useLayoutEffect(() => {
executorsRef.current = executors;
}, [executors]);
useEffect(() => {
if (!enabled || !taskId) return;
let active = true;
void fetchTaskEnvironment(taskId)
.then((env) => {
if (!active || !env) return;
let foundProfile: ExecutorProfile | undefined;
for (const executor of executorsRef.current) {
const match = (executor.profiles ?? []).find((p) => p.id === env.executor_profile_id);
if (match) {
foundProfile = {
...match,
executor_type: match.executor_type ?? executor.type,
executor_name: match.executor_name ?? executor.name,
};
break;
}
}
setProfile(foundProfile ?? null);
})
.catch(() => {});
return () => {
active = false;
};
}, [enabled, taskId, executorsFingerprint]);
if (!enabled || !taskId) return null;
return profile;
}
+2 -1
View File
@@ -27,9 +27,10 @@ export function useSummarizeSession() {
const transcript = formatTranscript(messages);
if (!transcript) return null;
// Sessionless: handoff often runs against a completed session whose
// agentctl is gone. Host utility executes the builtin summarize agent.
const result = await executeUtilityPrompt({
utility_agent_id: "builtin-summarize-session",
session_id: sessionId,
conversation_history: transcript,
});
return result.success ? (result.response ?? null) : null;