Files
kandev/apps/web/components/task/session-tab-menu.tsx
T
João Salavisa 9381a6a9f7 feat(mcp): session spawning, sibling messaging, and renameable tabs (#1734)
* fix(scripts): launch dev-isolated backend via __backend subcommand

The kandev binary became a bundle launcher with the restart supervisor
(ADR 0019) and exits without KANDEV_BUNDLE_DIR when invoked bare; the
isolated dev instance must run the backend entrypoint directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(mcp): session spawning, sibling messaging, and renameable tabs

- spawn_session_kandev spawns an additional agent session on an existing
  task (default: the caller's own) via LaunchSession(IntentStart), with
  optional agent_profile_id (defaults to the spawner's profile) and a
  session name; the initial prompt carries spawner attribution and reply
  instructions.
- message_task_kandev accepts an optional session_id to target a specific
  session, enabling same-task sibling messaging. Explicit targets are
  pinned through dispatch so the idle-path primary-session re-resolution
  cannot reroute a sibling message back to its sender (regression-tested).
- task_sessions gains a name column (migration ordered after the
  agent_execution_id-drop rebuild), a session.rename WS action, and inline
  tab rename UI; custom names take precedence over derived agent/model
  labels across the tab, agents dropdown, mobile list, chat header, and
  dockview panel titles.
- Inter-session messages stamp sender_session_name into metadata so the
  sender badge shows which session sent the message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): address review findings on session spawn/rename/messaging

- include name in task-session DTOs (list + full, no omitempty) so
  persisted tab names survive list hydration and clears propagate
- sanitize sender task/session ids embedded in <kandev-system> blocks
- reject cross-workspace spawn targets; require sender_task_id
- truncate session names by runes, not bytes
- distinguish not-found from internal errors in message target lookup
- guard TabRenameInput blur-commit after Enter/Escape resolution
- cap optimistic rename length client-side and toast on rename failure
- add tests: spawn launch-failure and cross-workspace paths, renameSession
  API client

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(mcp): round-two review fixes for spawn/rename lint and hardening

- extract spawn validation/authorization helpers (cyclop/nestif limits)
- make stripSystemTag replace until stable to block nested tag evasion
- load session before rename so a durable rename can never skip its
  broadcast; surface lookup failures to the caller instead
- drop redundant sessionLauncher nil check (guarded registration)
- add maxLength to the rename input matching the server cap
- add tests: nested sanitizer evasion, spawn rename-failure tolerance

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 17:26:50 +01:00

218 lines
6.1 KiB
TypeScript

"use client";
import { ContextMenuContent, ContextMenuItem, ContextMenuSeparator } from "@kandev/ui/context-menu";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import {
isSessionStoppable as isStoppable,
isSessionDeletable as isDeletable,
isSessionResumable as isResumable,
} from "@/hooks/domains/session/use-session-actions";
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";
/** Lifecycle callbacks the context menu needs from the owning tab. */
export type SessionTabMenuActions = {
handleSetPrimary: () => void;
handleStop: () => void;
handleResume: () => void;
handleCloseOthers: () => void;
};
export function DeleteSessionDialog({
open,
onOpenChange,
isPrimary,
sessionCount,
onConfirm,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
isPrimary: boolean;
sessionCount: number;
onConfirm: () => void;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete session?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>This will permanently delete the conversation history with this session.</p>
{isPrimary && sessionCount > 1 && (
<p className="mt-2 font-medium">
This is the primary session. Another session will be set as primary.
</p>
)}
{sessionCount === 1 && (
<p className="mt-2 font-medium">This is the only session for this task.</p>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onOpenChange(false);
onConfirm();
}}
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
export function SessionContextMenuItems({
sessionState,
isPrimary,
canShare,
taskId,
sessionId,
actions,
onDelete,
onShare,
onHandoffProfile,
onStartRename,
}: {
sessionState: TaskSessionState | null;
isPrimary: boolean;
canShare: boolean;
taskId: string | null;
sessionId: string | undefined;
actions: SessionTabMenuActions;
onDelete: () => void;
onShare: () => void;
onHandoffProfile: (profileId: string) => void;
onStartRename: () => void;
}) {
return (
<ContextMenuContent>
<ContextMenuItem className="cursor-pointer" onSelect={onStartRename}>
Rename…
</ContextMenuItem>
<ContextMenuItem
className="cursor-pointer"
onSelect={actions.handleSetPrimary}
disabled={isPrimary || !sessionState || !isStoppable(sessionState)}
>
Set as Primary
</ContextMenuItem>
<ContextMenuSeparator />
{sessionState && isStoppable(sessionState) && (
<ContextMenuItem className="cursor-pointer" onSelect={actions.handleStop}>
Stop
</ContextMenuItem>
)}
{sessionState && isResumable(sessionState) && (
<ContextMenuItem className="cursor-pointer" onSelect={actions.handleResume}>
Resume
</ContextMenuItem>
)}
{sessionState && isDeletable(sessionState) && (
<ContextMenuItem className="cursor-pointer text-destructive" onSelect={onDelete}>
Delete
</ContextMenuItem>
)}
{canShare && (
<>
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer" onSelect={onShare}>
Share
</ContextMenuItem>
</>
)}
{taskId && sessionId && (
<>
<ContextMenuSeparator />
<HandoffContextMenuSub taskId={taskId} onSelectProfile={onHandoffProfile} />
</>
)}
<ContextMenuSeparator />
<ContextMenuItem className="cursor-pointer" onSelect={actions.handleCloseOthers}>
Close Others
</ContextMenuItem>
</ContextMenuContent>
);
}
/** Delete / share / handoff dialogs rendered alongside the tab. */
export function SessionTabDialogs({
confirmDelete,
setConfirmDelete,
isPrimary,
sessionCount,
onConfirmDelete,
taskId,
sessionId,
shareOpen,
setShareOpen,
handoffOpen,
setHandoffOpen,
handoffPreset,
setHandoffPreset,
groupId,
}: {
confirmDelete: boolean;
setConfirmDelete: (open: boolean) => void;
isPrimary: boolean;
sessionCount: number;
onConfirmDelete: () => void;
taskId: string | null;
sessionId: string | undefined;
shareOpen: boolean;
setShareOpen: (open: boolean) => void;
handoffOpen: boolean;
setHandoffOpen: (open: boolean) => void;
handoffPreset: HandoffPreset | null;
setHandoffPreset: (preset: HandoffPreset | null) => void;
groupId: string | undefined;
}) {
return (
<>
<DeleteSessionDialog
open={confirmDelete}
onOpenChange={setConfirmDelete}
isPrimary={isPrimary}
sessionCount={sessionCount}
onConfirm={onConfirmDelete}
/>
{taskId && sessionId && (
<ShareDialog
open={shareOpen}
onOpenChange={setShareOpen}
taskId={taskId}
sessionId={sessionId}
/>
)}
{taskId && handoffPreset && (
<NewSessionDialog
open={handoffOpen}
onOpenChange={(open) => {
setHandoffOpen(open);
if (!open) setHandoffPreset(null);
}}
taskId={taskId}
groupId={groupId}
handoff={handoffPreset}
/>
)}
</>
);
}