45ee2ff48f
* feat(backend): key user shells by TaskEnvironmentID Sessions in the same task share a TaskEnvironment. Re-key the user shell runner so terminals are scoped per env, not per session — switching between sessions of the same task now sees the same shell list. - Add TaskEnvironmentID to AgentExecution / LaunchRequest / ExecutorCreateRequest / executor.LaunchAgentRequest, propagated through ToAgentExecution and the orchestrator launch path. - Add Manager.ResolveScopeKey(sessionID) — returns env when execution carries it, falls back to sessionID for legacy paths. - Rename runner param sessionID -> scopeID in interactive_shells.go; map keys are now scopeID:terminalID. - Shell handlers + WS terminal handler resolve sid -> scopeID via ResolveScopeKey before calling the runner. * feat(web): key dockview layout + portals to TaskEnvironmentID Switching between sessions of the same task no longer rebuilds the dockview layout — terminals and panels persist because layouts are env-scoped, not session-scoped. The bottom terminal also reuses a stable sessionId across same-env switches so it doesn't reconnect to a different shell. - Rename layout persistence: setSessionLayout/getSessionLayout/*SessionMaximizeState -> setEnvLayout/getEnvLayout/*EnvMaximizeState (kandev.dockview.env-layout. prefix). - Rename dockview-session-switch.ts -> dockview-env-switch.ts; performSessionSwitch -> performEnvSwitch with oldEnvId/newEnvId/activeSessionId. - dockview-store.ts: currentLayoutSessionId -> currentLayoutEnvId; switchSessionLayout -> switchEnvLayout(oldEnvId, newEnvId, activeSessionId). Same-env switches no-op. Drop _skipLayoutSwitchForSession + consumeSkipFlag — env-keyed action handles it structurally. - panel-portal-manager: PortalEntry.envId; releaseBySession -> releaseByEnv. ENV_SCOPED_COMPONENTS in dockview-desktop-layout (renamed from SESSION_SCOPED). - useEnvSwitchCleanup replaces useSessionSwitchCleanup; tracks env changes only. - All performLayoutSwitch callers (sidebar, dropdown, header, dialog) resolve sid -> envId via environmentIdBySessionId before switching. - bottom-terminal-panel: drop sessionId prop, use useEnvironmentSessionId(). - cleanupTaskStorage now takes envIds[] alongside sessionIds[] for env-scoped layout/maximize cleanup. * test: add coverage for env-keyed terminals + layout Playwright e2e (terminal-env-keyed.spec.ts) — switching between sessions of the same task keeps terminal scrollback intact and asserts both sessions share one task_environment_id. Update the comment in sessionless-task-switch.spec.ts to reference performEnvSwitch. * fix: address env layout PR feedback * test: fix flaky handshake auth check * fix: address env scope review findings
103 lines
4.0 KiB
TypeScript
103 lines
4.0 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useMemo } from "react";
|
|
import { IconStar } from "@tabler/icons-react";
|
|
import {
|
|
DropdownMenuItem,
|
|
DropdownMenuLabel,
|
|
DropdownMenuSeparator,
|
|
} from "@kandev/ui/dropdown-menu";
|
|
import { useAppStore } from "@/components/state-provider";
|
|
import { useDockviewStore } from "@/lib/state/dockview-store";
|
|
import { useTaskSessions } from "@/hooks/use-task-sessions";
|
|
import { addSessionPanel } from "@/lib/state/dockview-panel-actions";
|
|
import { getSessionStateIcon } from "@/lib/ui/state-icons";
|
|
import { AgentLogo } from "@/components/agent-logo";
|
|
import type { TaskSession } from "@/lib/types/http";
|
|
import type { AgentProfileOption } from "@/lib/state/slices";
|
|
|
|
type AgentInfo = { label: string; agentName: string };
|
|
|
|
function resolveAgentInfo(
|
|
session: TaskSession,
|
|
profilesById: Record<string, AgentProfileOption>,
|
|
): AgentInfo {
|
|
const profile = session.agent_profile_id ? profilesById[session.agent_profile_id] : null;
|
|
if (!profile) return { label: "Unknown agent", agentName: "" };
|
|
const parts = profile.label.split(" \u2022 ");
|
|
return { label: parts[1] || parts[0] || profile.label, agentName: profile.agent_name };
|
|
}
|
|
|
|
/**
|
|
* Renders session items inside the + dropdown menu.
|
|
* Each item shows session number, agent label, primary star, and state icon.
|
|
* Clicking focuses an existing tab or re-opens a closed one.
|
|
*/
|
|
export function SessionReopenMenuItems({ taskId, groupId }: { taskId: string; groupId?: string }) {
|
|
const { sessions } = useTaskSessions(taskId);
|
|
const api = useDockviewStore((s) => s.api);
|
|
const centerGroupId = useDockviewStore((s) => s.centerGroupId);
|
|
const agentProfiles = useAppStore((s) => s.agentProfiles.items);
|
|
const primarySessionId = useAppStore((s) => {
|
|
const task = s.kanban.tasks.find((t: { id: string }) => t.id === taskId);
|
|
return task?.primarySessionId ?? null;
|
|
});
|
|
|
|
const profilesById = useMemo(
|
|
() => Object.fromEntries(agentProfiles.map((p: AgentProfileOption) => [p.id, p])),
|
|
[agentProfiles],
|
|
);
|
|
|
|
const sortedSessions = useMemo(
|
|
() =>
|
|
[...sessions].sort(
|
|
(a, b) => new Date(a.started_at).getTime() - new Date(b.started_at).getTime(),
|
|
),
|
|
[sessions],
|
|
);
|
|
|
|
const handleClick = useCallback(
|
|
(sessionId: string, label: string, groupId?: string) => {
|
|
if (!api) return;
|
|
// Reopening a session within the same task = same env, so the env switch
|
|
// action no-ops naturally. We just create the chat panel.
|
|
addSessionPanel(api, groupId ?? centerGroupId, sessionId, label);
|
|
},
|
|
[api, centerGroupId],
|
|
);
|
|
|
|
if (sortedSessions.length === 0) return null;
|
|
|
|
return (
|
|
<>
|
|
<DropdownMenuLabel className="text-xs text-muted-foreground">Agents</DropdownMenuLabel>
|
|
{sortedSessions.map((session, index) => {
|
|
const info = resolveAgentInfo(session, profilesById);
|
|
const isPrimary = session.id === primarySessionId;
|
|
const isOpen = Boolean(api?.getPanel(`session:${session.id}`));
|
|
return (
|
|
<DropdownMenuItem
|
|
key={session.id}
|
|
onClick={() => handleClick(session.id, info.label, groupId)}
|
|
className={`cursor-pointer text-xs gap-1.5 ${isOpen ? "opacity-50" : ""}`}
|
|
data-testid={`reopen-session-${session.id}`}
|
|
>
|
|
<span className="w-5 shrink-0 text-muted-foreground text-right">#{index + 1}</span>
|
|
{info.agentName && (
|
|
<AgentLogo agentName={info.agentName} size={14} className="shrink-0" />
|
|
)}
|
|
<span className="flex-1 truncate">{info.label}</span>
|
|
{isPrimary && <IconStar className="h-3 w-3 fill-foreground/50 stroke-0 shrink-0" />}
|
|
{session.state !== "RUNNING" &&
|
|
session.state !== "STARTING" &&
|
|
session.state !== "WAITING_FOR_INPUT" && (
|
|
<span className="shrink-0">{getSessionStateIcon(session.state, "h-3 w-3")}</span>
|
|
)}
|
|
</DropdownMenuItem>
|
|
);
|
|
})}
|
|
<DropdownMenuSeparator />
|
|
</>
|
|
);
|
|
}
|