fix: debug task dialog selection restore (#1493)

* fix: debug task dialog selection restore

* fix: address task dialog debug review

* fix: address task dialog review feedback

* fix: finish task dialog debug review

* fix: keep portaled task selectors clickable

* test: cover portaled selector option clicks

---------

Co-authored-by: root <root@kandev.ts.net>
This commit is contained in:
Carlos Florêncio
2026-06-25 16:06:38 +01:00
committed by GitHub
parent 365a3bdd24
commit ec5a81f805
13 changed files with 388 additions and 68 deletions
+4 -2
View File
@@ -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({
</PopoverTrigger>
<PopoverContent
className={cn(
"w-[var(--radix-popover-trigger-width)] min-w-[300px] max-w-none p-0 max-h-[var(--radix-popover-content-available-height)]",
"w-[var(--radix-popover-trigger-width)] min-w-[min(300px,calc(100vw-2rem))] max-w-[calc(100vw-2rem)] p-0 max-h-[var(--radix-popover-content-available-height)] pointer-events-auto",
className,
)}
side={popoverSide}
align={popoverAlign}
portal={false}
portal={popoverPortal}
>
<Command
value={highlighted}
@@ -4,7 +4,7 @@ import { useEffect } from "react";
import type { AgentProfileOption } from "@/lib/state/slices";
import { getLocalStorage } from "@/lib/local-storage";
import { STORAGE_KEYS } from "@/lib/settings/constants";
import { createDebugLogger } from "@/lib/debug/log";
import { createDebugLogger, isDebug } from "@/lib/debug/log";
import type { DialogFormState, StoreSelections } from "@/components/task-create-dialog-types";
/**
@@ -43,6 +43,7 @@ export function decideAgentProfileAutopick(input: {
authLoaded: boolean;
executorProfileId: string;
hasExecutors: boolean;
lastAgentProfileId: string | null;
defaultAgentProfileId: string | null;
}): AutopickDecision {
if (!input.open) return { kind: "skip", reason: "closed" };
@@ -63,7 +64,7 @@ export function decideAgentProfileAutopick(input: {
}
if (input.compatibleAgentProfiles.length === 0) return { kind: "skip", reason: "no-compatible" };
const lastId = getLocalStorage<string | null>(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<string | null>(
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));
+140 -35
View File
@@ -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<string | null>(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<string | null>(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;
}
@@ -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 && (
<p className="text-[11px] text-muted-foreground mt-1">Agent set by workflow</p>
@@ -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 (
<div className="grid gap-4 grid-cols-1 sm:grid-cols-2">
<div>
<div className="grid min-w-0 grid-cols-1 gap-4 sm:grid-cols-2">
<div className="min-w-0">
<AgentColumn {...props} />
</div>
<div>
<div className="min-w-0">
<ExecutorProfileSelectorComponent
options={executorProfileOptions}
value={executorProfileId}
onValueChange={onExecutorProfileChange}
placeholder={executorsLoading ? "Loading profiles..." : "Select profile"}
disabled={executorsLoading}
popoverPortal
/>
</div>
</div>
@@ -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 (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div className="grid min-w-0 grid-cols-1 gap-3 sm:grid-cols-2">
<AgentSelectorComponent
options={agentProfileOptions}
value={agentProfileId}
onValueChange={onAgentProfileChange}
placeholder={agentProfilesLoading ? "Loading agent profiles..." : "Select agent profile"}
disabled={agentProfilesLoading || isCreatingSession}
popoverPortal
/>
<ExecutorProfileSelectorComponent
options={executorProfileOptions}
@@ -238,6 +245,7 @@ export const SessionSelectors = memo(function SessionSelectors({
onValueChange={onExecutorProfileChange}
placeholder={executorsLoading ? "Loading profiles..." : "Select profile"}
disabled={executorsLoading || isCreatingSession}
popoverPortal
/>
</div>
);
@@ -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],
);
@@ -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<typeof createTask>[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<string | null>(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);
}
@@ -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 (
<Combobox
@@ -166,8 +169,9 @@ export const AgentSelector = memo(function AgentSelector({
emptyMessage="No agent found."
disabled={disabled}
dropdownLabel="Agent Profile"
className={`min-w-[380px]${disabled ? "" : ` ${CURSOR_POINTER_CLASS}`}`}
triggerClassName={triggerClassName}
className={disabled ? undefined : CURSOR_POINTER_CLASS}
triggerClassName={cn("min-w-0", triggerClassName)}
popoverPortal={popoverPortal}
testId="agent-profile-selector"
/>
);
@@ -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 (
<Combobox
@@ -201,6 +207,7 @@ export const ExecutorSelector = memo(function ExecutorSelector({
dropdownLabel="Executor"
className={disabled ? undefined : CURSOR_POINTER_CLASS}
triggerClassName={triggerClassName}
popoverPortal={popoverPortal}
showSearch={false}
/>
);
@@ -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 (
<Combobox
@@ -234,7 +243,8 @@ export const ExecutorProfileSelector = memo(function ExecutorProfileSelector({
disabled={disabled}
dropdownLabel="Executor Profile"
className={disabled ? undefined : CURSOR_POINTER_CLASS}
triggerClassName={triggerClassName}
triggerClassName={cn("min-w-0", triggerClassName)}
popoverPortal={popoverPortal}
testId="executor-profile-selector"
/>
);
@@ -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,
@@ -283,7 +283,7 @@ function SessionFormHeader({
executorProfileName={executorProfileName}
/>
{showAgentSelector && (
<div className="space-y-1.5">
<div className="min-w-0 space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Agent Profile</label>
<AgentSelector
options={profileOptions}
@@ -291,6 +291,7 @@ function SessionFormHeader({
onValueChange={onProfileChange}
disabled={isCreating}
placeholder="Select agent profile"
popoverPortal
/>
</div>
)}
@@ -65,24 +65,26 @@ export function SelectorsRow({
}: SelectorsRowProps) {
const noAgents = profileOptions.length === 0;
return (
<div className={"grid gap-4 grid-cols-1" + (hideExecutor ? "" : " sm:grid-cols-2")}>
<div>
<div className={"grid min-w-0 grid-cols-1 gap-4" + (hideExecutor ? "" : " sm:grid-cols-2")}>
<div className="min-w-0">
<AgentSelector
options={profileOptions}
value={agentProfileId}
onValueChange={onAgentProfileChange}
disabled={disabled || noAgents}
placeholder={noAgents ? "No agents found" : "Select agent profile"}
popoverPortal
/>
</div>
{!hideExecutor && (
<div>
<div className="min-w-0">
<ExecutorProfileSelector
options={executorProfileOptions}
value={executorProfileId}
onValueChange={onExecutorProfileChange}
disabled={disabled}
placeholder="Select executor profile"
popoverPortal
/>
</div>
)}
+24
View File
@@ -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<void> {
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);
}
@@ -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",
);
}
});
});
+10
View File
@@ -65,6 +65,16 @@
* [file-browser:changes] session.workspace.file.changes events + folder refresh
*
* Task-create dialog (bug: "No compatible agent profiles for <executor>")
* [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 / …)