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:
@@ -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));
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 / …)
|
||||
|
||||
Reference in New Issue
Block a user