feat(web): add session handoff via agent tab context menu (#1317)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user