diff --git a/apps/web/components/combobox.tsx b/apps/web/components/combobox.tsx index 5a1662514c..226b3f62d7 100644 --- a/apps/web/components/combobox.tsx +++ b/apps/web/components/combobox.tsx @@ -49,6 +49,7 @@ interface ComboboxProps { dropdownTestId?: string; popoverSide?: "top" | "right" | "bottom" | "left"; popoverAlign?: "start" | "center" | "end"; + popoverPortal?: boolean; /** When true, the trigger always renders the plain label text instead of renderLabel. */ plainTrigger?: boolean; /** Optional custom filter; defaults to cmdk's built-in command-score. */ @@ -151,6 +152,7 @@ export const Combobox = memo(function Combobox({ dropdownTestId, popoverSide, popoverAlign = "start", + popoverPortal = false, plainTrigger = false, filter, headerAction, @@ -196,12 +198,12 @@ export const Combobox = memo(function Combobox({ (STORAGE_KEYS.LAST_AGENT_PROFILE_ID, null); + const lastId = input.lastAgentProfileId; if (lastId && input.compatibleAgentProfiles.some((p) => p.id === lastId)) { return { kind: "pick", source: "lastId", id: lastId }; } @@ -74,6 +75,39 @@ export function decideAgentProfileAutopick(input: { return { kind: "pick", source: "first", id: input.compatibleAgentProfiles[0].id }; } +function buildAgentAutopickDebugFields(input: { + decision: AutopickDecision; + agentProfileId: string; + selectedWorkflowId: string | null; + executorProfileId: string; + agentProfiles: AgentProfileOption[]; + compatibleAgentProfiles: AgentProfileOption[]; + authLoaded: boolean; + lastAgentProfileId: string | null; + defaultAgentProfileId: string | null; +}) { + const lastId = input.lastAgentProfileId; + const defId = input.defaultAgentProfileId; + return { + reason: input.decision.kind === "pick" ? input.decision.source : input.decision.reason, + pick: input.decision.kind === "pick" ? input.decision.id : "-", + current: input.agentProfileId || "-", + workflow_id: input.selectedWorkflowId ?? "-", + executor_profile_id: input.executorProfileId || "-", + local_storage_id: lastId ?? "-", + local_storage_valid: Boolean( + lastId && input.compatibleAgentProfiles.some((p) => p.id === lastId), + ), + workspace_default_id: defId ?? "-", + workspace_default_valid: Boolean( + defId && input.compatibleAgentProfiles.some((p) => p.id === defId), + ), + agent_count: input.agentProfiles.length, + compat_count: input.compatibleAgentProfiles.length, + auth_loaded: input.authLoaded, + }; +} + export function useWorkflowAgentProfileEffect( fs: DialogFormState, workflows: Array<{ id: string; agent_profile_id?: string }>, @@ -156,6 +190,11 @@ export function useAgentProfileAutopickEffect( const workflowHasAgent = selectedWorkflowId ? workflows.some((w) => w.id === selectedWorkflowId && w.agent_profile_id) : false; + const lastAgentProfileId = getLocalStorage( + STORAGE_KEYS.LAST_AGENT_PROFILE_ID, + null, + ); + const defaultAgentProfileId = workspaceDefaults?.default_agent_profile_id ?? null; const decision = decideAgentProfileAutopick({ open, agentProfileId, @@ -166,18 +205,25 @@ export function useAgentProfileAutopickEffect( authLoaded, executorProfileId, hasExecutors: executors.length > 0, - defaultAgentProfileId: workspaceDefaults?.default_agent_profile_id ?? null, - }); - autopickDebug(decision.kind, { - reason: decision.kind === "pick" ? decision.source : decision.reason, - pick: decision.kind === "pick" ? decision.id : "-", - current: agentProfileId || "-", - workflow_id: selectedWorkflowId ?? "-", - executor_profile_id: executorProfileId || "-", - agent_count: agentProfiles.length, - compat_count: compatibleAgentProfiles.length, - auth_loaded: authLoaded, + lastAgentProfileId, + defaultAgentProfileId, }); + if (isDebug()) { + autopickDebug( + decision.kind, + buildAgentAutopickDebugFields({ + decision, + agentProfileId, + selectedWorkflowId, + executorProfileId, + agentProfiles, + compatibleAgentProfiles, + authLoaded, + lastAgentProfileId, + defaultAgentProfileId, + }), + ); + } if (decision.kind === "pick") { const id = decision.id; void Promise.resolve().then(() => setAgentProfileId(id)); diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index 1a26e69be2..4a7fa7f7cd 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -22,12 +22,15 @@ import { useWorkflowAgentProfileEffect, } from "@/components/task-create-dialog-autopick"; import { computeSelectedRepoCount } from "@/components/task-create-dialog-computed"; +import { createDebugLogger, isDebug } from "@/lib/debug/log"; // Re-export autopick hooks for callers that imported them from this module. export { useWorkflowAgentProfileEffect }; // Also re-exported for the test file, which expects the symbol to live here. export { decideAgentProfileAutopick } from "@/components/task-create-dialog-autopick"; +const selectionDebug = createDebugLogger("task-create:selection"); + export function useWorkflowStepsEffect(fs: DialogFormState, workflowId: string | null) { const { selectedWorkflowId, setFetchedSteps } = fs; useEffect(() => { @@ -68,10 +71,25 @@ export function useRepositoryAutoSelectEffect( if (rows.length > 0) return; const lastUsedRepoId = getLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, null); let pickId: string | null = null; + let source = "empty-row"; if (lastUsedRepoId && repositories.some((r: Repository) => r.id === lastUsedRepoId)) { pickId = lastUsedRepoId; + source = "localStorage:lastRepositoryId"; } else if (repositories.length === 1) { pickId = repositories[0].id; + source = "single-workspace-repo"; + } + if (isDebug()) { + selectionDebug("repository-autopick", { + workspace_id: workspaceId, + local_storage_id: lastUsedRepoId ?? "-", + local_storage_valid: Boolean( + lastUsedRepoId && repositories.some((r: Repository) => r.id === lastUsedRepoId), + ), + repo_count: repositories.length, + source, + pick: pickId ?? "-", + }); } // Use the functional setter so the deferred microtask sees fresh state. // Without this, a sibling effect (resetTaskForm / useLockedFieldSync) that @@ -80,7 +98,15 @@ export function useRepositoryAutoSelectEffect( // and would blindly clobber the initialValues-seeded row. void Promise.resolve().then(() => { setRepositories((prev) => { - if (prev.length > 0) return prev; + if (prev.length > 0) { + if (isDebug()) { + selectionDebug("repository-autopick-skip", { + reason: "rows-seeded-before-microtask", + row_count: prev.length, + }); + } + return prev; + } return [ pickId ? { key: "row-0", repositoryId: pickId, branch: "" } @@ -275,6 +301,13 @@ function pickDefaultExecutorProfileId( return executorProfile?.id ?? eligibleProfiles[0].id; } +type ExecutorAutopickContext = { + executors: Executor[]; + workspaceDefaults: StoreSelections["workspaceDefaults"]; + noRepository: boolean; + preferLocalExecutor: boolean; +}; + function useMultiRepoGuardEffect( open: boolean, executorProfileId: string, @@ -307,6 +340,96 @@ function useMultiRepoGuardEffect( }, [open, executorProfileId, executors, selectedRepoCount, setExecutorProfileId]); } +function useExecutorIdAutopickEffect({ + open, + executorId, + context, + setExecutorId, +}: { + open: boolean; + executorId: string; + context: ExecutorAutopickContext; + setExecutorId: (id: string) => void; +}) { + const { executors, workspaceDefaults, noRepository, preferLocalExecutor } = context; + useEffect(() => { + if (!open || executorId || executors.length === 0) return; + const pick = pickDefaultExecutorId( + executors, + workspaceDefaults, + noRepository, + preferLocalExecutor, + ); + if (isDebug()) { + selectionDebug("executor-autopick", { + current: "-", + pick: pick ?? "-", + executor_count: executors.length, + workspace_default: workspaceDefaults?.default_executor_id ?? "-", + no_repository: noRepository, + prefer_local_executor: preferLocalExecutor, + }); + } + if (pick) void Promise.resolve().then(() => setExecutorId(pick)); + }, [ + open, + executorId, + executors, + workspaceDefaults, + setExecutorId, + noRepository, + preferLocalExecutor, + ]); +} + +function useExecutorProfileAutopickEffect({ + open, + executorProfileId, + context, + setExecutorProfileId, +}: { + open: boolean; + executorProfileId: string; + context: ExecutorAutopickContext; + setExecutorProfileId: (id: string) => void; +}) { + const { executors, workspaceDefaults, noRepository, preferLocalExecutor } = context; + useEffect(() => { + // Auto-select executor profile: last used (localStorage) → source-aware + // executor default → first eligible profile. + if (!open || executorProfileId || executors.length === 0) return; + const pick = pickDefaultExecutorProfileId( + executors, + workspaceDefaults, + noRepository, + preferLocalExecutor, + ); + if (isDebug()) { + const lastId = getLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, null); + const allProfiles = flattenExecutorProfiles(executors); + selectionDebug("executor-profile-autopick", { + current: "-", + pick: pick ?? "-", + local_storage_id: lastId ?? "-", + local_storage_valid: Boolean(lastId && allProfiles.some((p) => p.id === lastId)), + profile_count: allProfiles.length, + workspace_default_executor: workspaceDefaults?.default_executor_id ?? "-", + no_repository: noRepository, + prefer_local_executor: preferLocalExecutor, + }); + } + if (pick) void Promise.resolve().then(() => setExecutorProfileId(pick)); + }, [ + open, + executorProfileId, + executors, + workspaceDefaults, + setExecutorProfileId, + noRepository, + preferLocalExecutor, + ]); +} + export function useDefaultSelectionsEffect( fs: DialogFormState, open: boolean, @@ -326,47 +449,23 @@ export function useDefaultSelectionsEffect( } = fs; const preferLocalExecutor = !noRepository && !useRemote && repositories.some((row) => Boolean(row.localPath)); + const executorAutopickContext = useMemo( + () => ({ executors, workspaceDefaults, noRepository, preferLocalExecutor }), + [executors, workspaceDefaults, noRepository, preferLocalExecutor], + ); useAgentProfileAutopickEffect(fs, open, sel, workflows); - - useEffect(() => { - if (!open || executorId || executors.length === 0) return; - const pick = pickDefaultExecutorId( - executors, - workspaceDefaults, - noRepository, - preferLocalExecutor, - ); - if (pick) void Promise.resolve().then(() => setExecutorId(pick)); - }, [ + useExecutorIdAutopickEffect({ open, executorId, - executors, - workspaceDefaults, + context: executorAutopickContext, setExecutorId, - noRepository, - preferLocalExecutor, - ]); - - useEffect(() => { - // Auto-select executor profile: last used (localStorage) → source-aware - // executor default → first eligible profile. - if (!open || executorProfileId || executors.length === 0) return; - const pick = pickDefaultExecutorProfileId( - executors, - workspaceDefaults, - noRepository, - preferLocalExecutor, - ); - if (pick) void Promise.resolve().then(() => setExecutorProfileId(pick)); - }, [ + }); + useExecutorProfileAutopickEffect({ open, executorProfileId, - executors, - workspaceDefaults, + context: executorAutopickContext, setExecutorProfileId, - noRepository, - preferLocalExecutor, - ]); + }); // Derive executorId from the selected executor profile useEffect(() => { @@ -374,6 +473,12 @@ export function useDefaultSelectionsEffect( for (const executor of executors) { const match = (executor.profiles ?? []).find((p) => p.id === executorProfileId); if (match) { + if (isDebug()) { + selectionDebug("executor-derived-from-profile", { + executor_profile_id: executorProfileId, + executor_id: executor.id, + }); + } void Promise.resolve().then(() => setExecutorId(executor.id)); return; } diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index 40e84ee40d..988c5aff17 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -41,6 +41,7 @@ type CreateEditSelectorsProps = { disabled: boolean; placeholder: string; triggerClassName?: string; + popoverPortal?: boolean; }>; ExecutorProfileSelectorComponent: React.ComponentType<{ options: Array<{ value: string; label: string; renderLabel?: () => React.ReactNode }>; @@ -49,6 +50,7 @@ type CreateEditSelectorsProps = { disabled: boolean; placeholder: string; triggerClassName?: string; + popoverPortal?: boolean; }>; workflowAgentLocked: boolean; noCompatibleAgent: boolean; @@ -137,6 +139,7 @@ function AgentColumn({ onValueChange={onAgentProfileChange} placeholder={placeholder} disabled={agentProfilesLoading || isCreatingSession || workflowAgentLocked} + popoverPortal /> {workflowAgentLocked && (

Agent set by workflow

@@ -161,17 +164,18 @@ export const CreateEditSelectors = memo(function CreateEditSelectors( // branch strategy) live in the chip row above the description; this row // carries only agent and executor profile selectors. return ( -
-
+
+
-
+
@@ -199,6 +203,7 @@ type SessionSelectorsProps = { disabled: boolean; placeholder: string; triggerClassName?: string; + popoverPortal?: boolean; }>; ExecutorProfileSelectorComponent: React.ComponentType<{ options: Array<{ value: string; label: string; renderLabel?: () => React.ReactNode }>; @@ -207,6 +212,7 @@ type SessionSelectorsProps = { disabled: boolean; placeholder: string; triggerClassName?: string; + popoverPortal?: boolean; }>; }; @@ -224,13 +230,14 @@ export const SessionSelectors = memo(function SessionSelectors({ ExecutorProfileSelectorComponent, }: SessionSelectorsProps) { return ( -
+
); diff --git a/apps/web/components/task-create-dialog-handlers.ts b/apps/web/components/task-create-dialog-handlers.ts index d5307268df..c5ef64980b 100644 --- a/apps/web/components/task-create-dialog-handlers.ts +++ b/apps/web/components/task-create-dialog-handlers.ts @@ -6,6 +6,7 @@ import type { DialogFormState, TaskRepoRow } from "@/components/task-create-dial import { removeLocalStorage, setLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; import { updateUserSettings } from "@/lib/api/domains/settings-api"; +import { createDebugLogger } from "@/lib/debug/log"; type TaskCreateLastUsedPatch = { repository_id?: string; @@ -17,9 +18,12 @@ type TaskCreateLastUsedPatch = { let pendingLastUsed: TaskCreateLastUsedPatch = {}; let lastUsedSync = Promise.resolve(); const PENDING_LAST_USED_SYNC_KEY = "kandev.taskCreateLastUsed.pendingSync"; +const LOCAL_STORAGE_WRITE_EVENT = "localStorage-write"; +const lastUsedDebug = createDebugLogger("task-create:last-used"); export function resetTaskCreateLastUsedSync() { pendingLastUsed = {}; + lastUsedDebug("pending-reset"); } function readPendingLastUsedSync(): TaskCreateLastUsedPatch { @@ -39,6 +43,7 @@ function readPendingLastUsedSync(): TaskCreateLastUsedPatch { typeof record.executor_profile_id === "string" ? record.executor_profile_id : undefined, }; } catch { + lastUsedDebug("pending-read-failed"); return {}; } } @@ -50,18 +55,25 @@ function persistPendingLastUsedSync(patch: TaskCreateLastUsedPatch) { export function syncTaskCreateLastUsed(patch: TaskCreateLastUsedPatch) { pendingLastUsed = { ...readPendingLastUsedSync(), ...pendingLastUsed, ...patch }; const payload = { ...pendingLastUsed }; + lastUsedDebug("sync-queued", { patch, payload }); persistPendingLastUsedSync(payload); lastUsedSync = lastUsedSync .catch(() => undefined) .then(() => updateUserSettings({ task_create_last_used: payload }) .then(() => { + lastUsedDebug("sync-success", { payload }); if (JSON.stringify(pendingLastUsed) === JSON.stringify(payload)) { pendingLastUsed = {}; removeLocalStorage(PENDING_LAST_USED_SYNC_KEY); } }) - .catch(() => undefined), + .catch((error) => { + lastUsedDebug("sync-failed", { + payload, + error: error instanceof Error ? error.message : String(error), + }); + }), ); } @@ -104,6 +116,11 @@ function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[]) if (isWorkspaceRepo) { setLocalStorage(STORAGE_KEYS.LAST_REPOSITORY_ID, value); syncTaskCreateLastUsed({ repository_id: value }); + lastUsedDebug(LOCAL_STORAGE_WRITE_EVENT, { + key: "lastRepositoryId", + value, + row_key: key, + }); } // Switching the repo invalidates whatever local-status the fresh-branch // panel had cached. @@ -117,6 +134,7 @@ function useRepositoryHandlers(fs: DialogFormState, repositories: Repository[]) fs.updateRepository(key, { branch: value }); setLocalStorage(STORAGE_KEYS.LAST_BRANCH, value); syncTaskCreateLastUsed({ branch: value }); + lastUsedDebug(LOCAL_STORAGE_WRITE_EVENT, { key: "lastBranch", value, row_key: key }); }, [fs], ); @@ -130,6 +148,7 @@ function useProfileAndNameHandlers(fs: DialogFormState) { fs.setAgentProfileId(value); setLocalStorage(STORAGE_KEYS.LAST_AGENT_PROFILE_ID, value); syncTaskCreateLastUsed({ agent_profile_id: value }); + lastUsedDebug(LOCAL_STORAGE_WRITE_EVENT, { key: "lastAgentProfileId", value }); }, [fs], ); @@ -138,6 +157,7 @@ function useProfileAndNameHandlers(fs: DialogFormState) { fs.setExecutorProfileId(value); setLocalStorage(STORAGE_KEYS.LAST_EXECUTOR_PROFILE_ID, value); syncTaskCreateLastUsed({ executor_profile_id: value }); + lastUsedDebug(LOCAL_STORAGE_WRITE_EVENT, { key: "lastExecutorProfileId", value }); }, [fs], ); diff --git a/apps/web/components/task-create-dialog-helpers.ts b/apps/web/components/task-create-dialog-helpers.ts index 2716c00b74..78bfc0ea3d 100644 --- a/apps/web/components/task-create-dialog-helpers.ts +++ b/apps/web/components/task-create-dialog-helpers.ts @@ -12,6 +12,7 @@ import { parseGitHubAnyUrl } from "@/hooks/domains/github/use-pr-info-by-url"; import { selectPreferredBranch } from "@/lib/utils"; import { getLocalStorage } from "@/lib/local-storage"; import { STORAGE_KEYS } from "@/lib/settings/constants"; +import { createDebugLogger } from "@/lib/debug/log"; import { useContextFilesStore } from "@/lib/state/context-files-store"; import { linkToTask } from "@/lib/links"; import { INTENT_PLAN } from "@/lib/state/layout-manager"; @@ -20,6 +21,7 @@ import type { FileAttachment } from "@/components/task/chat/file-attachment"; import type { MessageAttachment } from "@/lib/services/session-launch-service"; type CreateTaskParams = Parameters[0]; +const selectionDebug = createDebugLogger("task-create:selection"); export type { CreateTaskParams }; @@ -49,17 +51,32 @@ export function toMessageAttachments( export function autoSelectBranch(branchList: Branch[], setBranch: (value: string) => void): void { const lastUsedBranch = getLocalStorage(STORAGE_KEYS.LAST_BRANCH, null); - if ( + const lastUsedValid = Boolean( lastUsedBranch && branchList.some((b) => { const displayName = b.type === "remote" && b.remote ? `${b.remote}/${b.name}` : b.name; return displayName === lastUsedBranch; - }) - ) { + }), + ); + if (lastUsedBranch && lastUsedValid) { + selectionDebug("branch-autopick", { + source: "localStorage:lastBranch", + pick: lastUsedBranch, + local_storage_value: lastUsedBranch, + local_storage_valid: true, + branch_count: branchList.length, + }); setBranch(lastUsedBranch); return; } const preferredBranch = selectPreferredBranch(branchList); + selectionDebug("branch-autopick", { + source: preferredBranch ? "preferred" : "none", + pick: preferredBranch ?? "-", + local_storage_value: lastUsedBranch ?? "-", + local_storage_valid: lastUsedValid, + branch_count: branchList.length, + }); if (preferredBranch) setBranch(preferredBranch); } diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 4f85f18c61..f065992a8b 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -26,6 +26,7 @@ import { VoiceInputButton } from "@/components/task/chat/voice-input-button"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; import { useTaskCreatePromptMention } from "@/hooks/use-task-create-prompt-mention"; +import { cn } from "@/lib/utils"; const CURSOR_POINTER_CLASS = "cursor-pointer"; @@ -146,6 +147,7 @@ type AgentSelectorProps = { disabled: boolean; placeholder: string; triggerClassName?: string; + popoverPortal?: boolean; }; export const AgentSelector = memo(function AgentSelector({ @@ -155,6 +157,7 @@ export const AgentSelector = memo(function AgentSelector({ disabled, placeholder, triggerClassName, + popoverPortal, }: AgentSelectorProps) { return ( ); @@ -180,6 +184,7 @@ type ExecutorSelectorProps = { disabled: boolean; placeholder: string; triggerClassName?: string; + popoverPortal?: boolean; }; export const ExecutorSelector = memo(function ExecutorSelector({ @@ -189,6 +194,7 @@ export const ExecutorSelector = memo(function ExecutorSelector({ disabled, placeholder, triggerClassName, + popoverPortal, }: ExecutorSelectorProps) { return ( ); @@ -213,6 +220,7 @@ type ExecutorProfileSelectorProps = { disabled: boolean; placeholder: string; triggerClassName?: string; + popoverPortal?: boolean; }; export const ExecutorProfileSelector = memo(function ExecutorProfileSelector({ @@ -222,6 +230,7 @@ export const ExecutorProfileSelector = memo(function ExecutorProfileSelector({ disabled, placeholder, triggerClassName, + popoverPortal, }: ExecutorProfileSelectorProps) { return ( ); diff --git a/apps/web/components/task-create-dialog-state.ts b/apps/web/components/task-create-dialog-state.ts index 82450ab658..c7dbeda231 100644 --- a/apps/web/components/task-create-dialog-state.ts +++ b/apps/web/components/task-create-dialog-state.ts @@ -24,6 +24,9 @@ import { useRepositoriesState, } from "@/components/task-create-dialog-repositories-state"; import { useDialogComputed } from "@/components/task-create-dialog-computed"; +import { createDebugLogger } from "@/lib/debug/log"; + +const stateDebug = createDebugLogger("task-create:state"); export type { StepType, @@ -88,6 +91,15 @@ function useFormResetEffects({ setOpenCycle((c) => c + 1); const defaults = resolveFormDefaults(initialValues, workspaceId); + stateDebug("open-reset", { + workspace_id: workspaceId ?? "-", + workflow_id: workflowId ?? "-", + source: defaults.source, + title_present: defaults.name.trim().length > 0, + description_present: defaults.description.trim().length > 0, + initial_repository_id: initialValues?.repositoryId ?? "-", + initial_branch: initialValues?.branch ?? initialValues?.checkoutBranch ?? "-", + }); setCurrentDefaults(defaults); resetTaskForm(resetters, defaults.name, defaults.description, workflowId, initialValues); setDraftDescription(defaults.description); @@ -96,6 +108,11 @@ function useFormResetEffects({ useEffect(() => { if (!open) return; + stateDebug("discovery-reset", { + workspace_id: workspaceId ?? "-", + github_url: initialValues?.githubUrl ?? "-", + seeded_remote_branch: initialValues?.checkoutBranch ?? initialValues?.branch ?? "-", + }); resetDiscoveryState(resetters, initialValues); // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, workspaceId]); @@ -121,9 +138,19 @@ function resolveFormDefaults( return { name: draft?.title ?? initTitle, description: draft?.description ?? initDesc, + source: resolveDefaultsSource(Boolean(draft), initialValues), }; } +function resolveDefaultsSource( + hasDraft: boolean, + initialValues: TaskCreateDialogInitialValues | undefined, +) { + if (hasDraft) return "draft"; + if (hasUserContent(initialValues)) return "initial-values"; + return "empty"; +} + /** Resets task form fields to specified values */ function resetTaskForm( resetters: FormResetters, diff --git a/apps/web/components/task/new-session-dialog.tsx b/apps/web/components/task/new-session-dialog.tsx index 6f3389ca63..d89e0cbab2 100644 --- a/apps/web/components/task/new-session-dialog.tsx +++ b/apps/web/components/task/new-session-dialog.tsx @@ -283,7 +283,7 @@ function SessionFormHeader({ executorProfileName={executorProfileName} /> {showAgentSelector && ( -
+
)} diff --git a/apps/web/components/task/new-subtask-form-parts.tsx b/apps/web/components/task/new-subtask-form-parts.tsx index dbf754062d..f5b7cbf701 100644 --- a/apps/web/components/task/new-subtask-form-parts.tsx +++ b/apps/web/components/task/new-subtask-form-parts.tsx @@ -65,24 +65,26 @@ export function SelectorsRow({ }: SelectorsRowProps) { const noAgents = profileOptions.length === 0; return ( -
-
+
+
{!hideExecutor && ( -
+
)} diff --git a/apps/web/e2e/helpers/layout-assertions.ts b/apps/web/e2e/helpers/layout-assertions.ts index aeb3d6bfe2..51cfbed013 100644 --- a/apps/web/e2e/helpers/layout-assertions.ts +++ b/apps/web/e2e/helpers/layout-assertions.ts @@ -102,3 +102,27 @@ export async function assertNoDocumentHorizontalOverflow( `${label}: scrollWidth (${widths.scroll}) exceeds clientWidth (${widths.client})`, ).toBeLessThanOrEqual(widths.client + 1); } + +/** + * Asserts that a visible locator fits inside the viewport horizontally. + * Useful for popovers that portal outside their dialog/container. + */ +export async function assertLocatorWithinViewportX( + locator: Locator, + label = "element", +): Promise { + const box = await locator.boundingBox(); + expect(box, `${label}: locator has no bounding box`).not.toBeNull(); + if (!box) return; + const viewport = locator.page().viewportSize(); + expect(viewport, `${label}: page has no viewport`).not.toBeNull(); + if (!viewport) return; + expect( + box.x, + `${label}: left edge ${box.x.toFixed(1)} is outside viewport`, + ).toBeGreaterThanOrEqual(-1); + expect( + box.x + box.width, + `${label}: right edge ${(box.x + box.width).toFixed(1)} exceeds viewport ${viewport.width}`, + ).toBeLessThanOrEqual(viewport.width + 1); +} diff --git a/apps/web/e2e/tests/task/dialog-long-text-overflow.spec.ts b/apps/web/e2e/tests/task/dialog-long-text-overflow.spec.ts index 868a1e3766..b1f7a8bb3d 100644 --- a/apps/web/e2e/tests/task/dialog-long-text-overflow.spec.ts +++ b/apps/web/e2e/tests/task/dialog-long-text-overflow.spec.ts @@ -1,5 +1,9 @@ import { test, expect } from "../../fixtures/test-base"; -import { assertNoDescendantOverflowsRight } from "../../helpers/layout-assertions"; +import type { Locator } from "@playwright/test"; +import { + assertLocatorWithinViewportX, + assertNoDescendantOverflowsRight, +} from "../../helpers/layout-assertions"; import { useRegularMode } from "../../helpers/regular-mode"; import { KanbanPage } from "../../pages/kanban-page"; import { SessionPage } from "../../pages/session-page"; @@ -9,6 +13,26 @@ useRegularMode(); const LONG_UNBROKEN_TEXT = `review-${"x".repeat(240)}-https://github.com/example/repo/commit/${"a".repeat(80)}`; +async function assertProfileDropdownFits(trigger: Locator, dropdownLabel: string) { + await trigger.click(); + const dropdown = trigger + .page() + .locator('[data-slot="popover-content"]') + .filter({ hasText: dropdownLabel }) + .last(); + await expect(dropdown).toBeVisible(); + await assertLocatorWithinViewportX(dropdown, dropdownLabel); + const searchInput = dropdown.getByPlaceholder(/Search (agents|profiles)/); + if ((await searchInput.count()) > 0) { + await searchInput.click(); + await expect(searchInput).toBeFocused(); + } + const option = dropdown.getByRole("option").first(); + await expect(option).toBeVisible(); + await option.click(); + await expect(dropdown).not.toBeVisible(); +} + test.describe("Dialog long text layout", () => { test("agent, task, and subtask dialogs stay within their modal bounds", async ({ testPage, @@ -24,7 +48,15 @@ test.describe("Dialog long text layout", () => { await testPage.getByTestId("task-title-input").fill(LONG_UNBROKEN_TEXT); await testPage.getByTestId("task-description-input").fill(LONG_UNBROKEN_TEXT); await assertNoDescendantOverflowsRight(createDialog, "create task dialog"); - await testPage.keyboard.press("Escape"); + await assertProfileDropdownFits( + testPage.getByTestId("agent-profile-selector"), + "Agent Profile", + ); + await assertProfileDropdownFits( + testPage.getByTestId("executor-profile-selector"), + "Executor Profile", + ); + await createDialog.getByRole("button", { name: "Cancel" }).click(); await expect(createDialog).not.toBeVisible(); const task = await apiClient.createTaskWithAgent( @@ -48,7 +80,13 @@ test.describe("Dialog long text layout", () => { await expect(newSessionDialog).toBeVisible(); await session.newSessionPromptInput().fill(LONG_UNBROKEN_TEXT); await assertNoDescendantOverflowsRight(newSessionDialog, "new agent dialog"); - await testPage.keyboard.press("Escape"); + if ((await testPage.getByTestId("agent-profile-selector").count()) > 0) { + await assertProfileDropdownFits( + testPage.getByTestId("agent-profile-selector"), + "Agent Profile", + ); + } + await newSessionDialog.getByRole("button", { name: "Cancel" }).click(); await expect(newSessionDialog).not.toBeVisible(); await testPage.getByTestId("sidebar-new-subtask").click(); @@ -57,5 +95,15 @@ test.describe("Dialog long text layout", () => { await testPage.getByTestId("subtask-title-input").fill(LONG_UNBROKEN_TEXT); await testPage.getByTestId("subtask-prompt-input").fill(LONG_UNBROKEN_TEXT); await assertNoDescendantOverflowsRight(subtaskDialog, "new subtask dialog"); + await assertProfileDropdownFits( + testPage.getByTestId("agent-profile-selector"), + "Agent Profile", + ); + if ((await testPage.getByTestId("executor-profile-selector").count()) > 0) { + await assertProfileDropdownFits( + testPage.getByTestId("executor-profile-selector"), + "Executor Profile", + ); + } }); }); diff --git a/apps/web/lib/debug/log.ts b/apps/web/lib/debug/log.ts index ea57ba9523..206049a075 100644 --- a/apps/web/lib/debug/log.ts +++ b/apps/web/lib/debug/log.ts @@ -65,6 +65,16 @@ * [file-browser:changes] session.workspace.file.changes events + folder refresh * * Task-create dialog (bug: "No compatible agent profiles for ") + * [task-create:state] dialog open-cycle reset decisions: draft vs + * initial values, seeded repo/branch hints, and + * source-mode/discovery reset. + * [task-create:selection] repository/executor/executor-profile auto-pick + * decisions: localStorage id, validity, workspace + * defaults from settings, selected source, and + * races where another effect seeded rows first. + * [task-create:last-used] manual selector persistence: localStorage writes, + * pending task_create_last_used payloads, DB sync + * success/failure, and pending-sync recovery. * [executor-compat:specs] remote-auth catalog fetch (count + agent ids) * [executor-compat] per-agent compat decision: ok + reason * (no-spec / no-creds / files-match / env-secret / …)