Files
kandev/apps/web/components/task-create-dialog.tsx
T
João Salavisa 54b98ed9eb feat(jira): add Jira integration for ticket browsing, import, and task linking (#705)
* feat(jira): add Jira integration for ticket browsing, import, and task linking

Adds a workspace-scoped Atlassian Cloud integration: per-workspace settings
(site URL, email, API token or session cookie), encrypted secret storage,
and a REST client for tickets, projects, search, and transitions. A
background poller probes credentials on a 90s cadence and the keepalive
runs immediately on save so the UI's connected indicator updates without
delay.

The /jira page provides ticket browsing with filter pills, custom JQL,
saved views (including JQL), and configurable task-launch presets that
prefill TaskCreateDialog. JiraLinkButton on the task top bar links an
existing task to a ticket by prepending its key to the title; the ticket
popover surfaces details, transitions, and re-auth prompts on session
expiry. Topbar shows separate icon buttons for GitHub and Jira when each
is configured.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): address PR review feedback

- Token-based pagination (pageToken/isLast) replaces broken offset/total pagination
- Race-fix in service.clientFor: re-check cache after acquiring write lock
- sync.Mutex guards poller started/cancel state against concurrent Start/Stop
- Channel-based + atomic.Int32 test sync replaces time.Sleep polling in poller_test
- Async auth probe on SetConfig with detached context.Background goroutine
- Distinguish 4xx vs 5xx in httpSetConfig via ErrInvalidConfig sentinel
- Propagate 4xx APIErrors from ListTransitions in GetTicket
- Distinguish "not found" from real errors in jiraSecretAdapter.Exists
- Log secret Exists errors instead of swallowing
- walkADF: handle softBreak (was only hardBreak despite the doc-comment)
- Consolidate JIRA_KEY_RE in jira-ticket-common; remove duplicates
- ticket-row imports formatRelative/statusBadgeClass from common
- isFilterState type guard validates SavedView.filters shape
- task-presets readStorage preserves user's "cleared all" intent (returns [])
- task-presets-section gates draft sync on `loaded` to avoid wiping in-progress edits
- jira-api: caller `init` no longer overrides method/body via spread order
- jira-settings: disable Test button when no saved/typed secret

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): record auth probe failure even when probe ctx expires

- RecordAuthHealth now uses a fresh context.Background-derived ctx with a
  5s timeout for the UpdateAuthHealth DB write, so a probe that consumes
  its full 15s deadline still persists the failure (greptile P1).
- SetConfig goroutine drops its redundant outer timeout — RecordAuthHealth
  manages its own probe timeout, so context.Background is enough.
- Add Service.SetProbeHook so tests can synchronize on probe completion;
  service_test waitForAuthProbe now blocks on the hook channel instead of
  time.Sleep polling.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): address coderabbit review feedback (round 3)

- url.PathEscape ticketKey in GetTicket/ListTransitions/DoTransition paths
  to harden against malicious keys ('../../foo', 'KEY?override=x').
- factoryHit on the test fixture is now sync/atomic.Int32; the async probe
  goroutine spawned by SetConfig races the test thread under -race.
- GetConfig logs Reveal failures (was silently dropped, leaving the UI to
  show "no expiry info" indistinguishably from a freshly-saved cookie).
- DeleteConfig logs secret-delete errors instead of swallowing them.
- clientFor distinguishes a secret-store failure from "not configured" so
  the UI doesn't render the configure-CTA for transient backend errors.
- Unify ErrNotConfigured wire code: HTTP and WebSocket both return
  "JIRA_NOT_CONFIGURED" via a shared constant.
- JiraLinkButton strips a leading "PROJ-123: " before re-prefixing so
  re-linking a task replaces the prefix instead of stacking it.
- jira-settings clears testResult on save/delete so the alert reflects
  only the currently-saved credentials.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(jira): cover Reveal error path in clientFor

Reveal-error case is distinct from "no config row" — the former wraps
the underlying error, the latter returns ErrNotConfigured. Without
this test the changed contract has no regression coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): route secret adapter Set through Exists

Treating every Get error as "not found" turned a transient DB error on
an existing row into a constraint-violation Create that masked the real
cause. Reuse Exists so the "secret not found:" prefix is the only path
that falls through to Create.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(jira): cover jiraSecretAdapter.Set branching

Routing Set through Exists changed the contract: a transient DB error
in Get now propagates instead of cascading into a constraint-violation
Create. Add three tests so a future refactor cannot regress this without
CI catching it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(jira): split Reveal errors and guard Save for new configs

- resolveCredentials now mirrors the clientFor split: a Reveal error
  surfaces as `read jira secret: %w` with a warn log instead of
  collapsing into "no token stored — paste one to test".
- disableSave gains the same missing-secret guard as disableTest so a
  brand-new config can no longer POST an empty secret and leave the
  workspace stuck with hasSecret=false.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-25 22:47:45 +01:00

601 lines
21 KiB
TypeScript

"use client";
import { FormEvent, useCallback } from "react";
import type { JiraTicket } from "@/lib/types/jira";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@kandev/ui/dialog";
import type { Task } from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices";
import { SHORTCUTS } from "@/lib/keyboard/constants";
import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { useKeyboardShortcutHandler } from "@/hooks/use-keyboard-shortcut";
import { useUtilityAgentGenerator } from "@/hooks/use-utility-agent-generator";
import { TaskCreateDialogFooter } from "@/components/task-create-dialog-footer";
import {
CreateEditSelectors,
SessionSelectors,
WorkflowSection,
DialogPromptSection,
} from "@/components/task-create-dialog-form-body";
import {
useRepositoryOptions,
useBranchOptions,
useAgentProfileOptions,
} from "@/components/task-create-dialog-options";
import {
RepositorySelector,
BranchSelector,
AgentSelector,
ExecutorProfileSelector,
InlineTaskName,
} from "@/components/task-create-dialog-selectors";
import { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit";
import { useToast } from "@/components/toast-provider";
import {
useDialogFormState,
useTaskCreateDialogEffects,
useDialogHandlers,
useSessionRepoName,
useTaskCreateDialogData,
computeIsTaskStarted,
type DialogFormState,
type TaskCreateDialogInitialValues,
} from "@/components/task-create-dialog-state";
interface TaskCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
mode?: "create" | "edit" | "session";
workspaceId: string | null;
workflowId: string | null;
defaultStepId: string | null;
steps: Array<{
id: string;
title: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
};
}>;
editingTask?: {
id: string;
title: string;
description?: string;
workflowStepId: string;
state?: Task["state"];
repositoryId?: string;
} | null;
onSuccess?: (
task: Task,
mode: "create" | "edit",
meta?: { taskSessionId?: string | null },
) => void;
onCreateSession?: (data: { prompt: string; agentProfileId: string; executorId: string }) => void;
initialValues?: TaskCreateDialogInitialValues;
taskId?: string | null;
parentTaskId?: string;
}
function getRepositoryPlaceholder(
workspaceId: string | null,
repositoriesLoading: boolean,
discoverReposLoading: boolean,
) {
if (!workspaceId) return "Select workspace first";
if (repositoriesLoading || discoverReposLoading) return "Loading...";
return "Select repository";
}
type DialogHeaderContentProps = {
isCreateMode: boolean;
isEditMode: boolean;
isTaskStarted: boolean;
sessionRepoName?: string;
initialTitle?: string;
taskName: string;
repositoryId: string;
discoveredRepoPath: string;
workspaceId: string | null;
repositoriesLoading: boolean;
discoverReposLoading: boolean;
headerRepositoryOptions: ReturnType<typeof useRepositoryOptions>["headerRepositoryOptions"];
onRepositoryChange: (v: string) => void;
onTaskNameChange: (v: string) => void;
useGitHubUrl: boolean;
githubUrl: string;
githubUrlError: string | null;
onToggleGitHubUrl: () => void;
onGitHubUrlChange: (v: string) => void;
};
function RepoSourceInput({
useGitHubUrl,
githubUrl,
githubUrlError,
onGitHubUrlChange,
isTaskStarted,
headerRepositoryOptions,
repositoryId,
discoveredRepoPath,
onRepositoryChange,
workspaceId,
repositoriesLoading,
discoverReposLoading,
}: Pick<
DialogHeaderContentProps,
| "useGitHubUrl"
| "githubUrl"
| "githubUrlError"
| "onGitHubUrlChange"
| "isTaskStarted"
| "headerRepositoryOptions"
| "repositoryId"
| "discoveredRepoPath"
| "onRepositoryChange"
| "workspaceId"
| "repositoriesLoading"
| "discoverReposLoading"
>): React.ReactNode {
if (useGitHubUrl) {
return (
<div className="relative">
<input
type="text"
value={githubUrl}
onChange={(e) => onGitHubUrlChange(e.target.value)}
placeholder="github.com/owner/repo"
data-testid="github-url-input"
aria-label="GitHub repository URL"
aria-invalid={!!githubUrlError}
aria-describedby={githubUrlError ? "github-url-error" : undefined}
size={Math.max((githubUrl || "").length + 1, "github.com/owner/repo".length)}
className={`bg-transparent border-none outline-none focus:ring-0 text-sm font-medium min-w-0 h-7 rounded-md px-2 hover:bg-muted focus:bg-muted transition-colors placeholder:text-muted-foreground ${githubUrlError ? "text-destructive" : ""}`}
disabled={isTaskStarted}
autoFocus
/>
{githubUrlError && (
<div
id="github-url-error"
role="alert"
className="absolute left-0 top-full mt-1 z-50 rounded-md border bg-popover px-2 py-1 text-[11px] text-destructive shadow-md whitespace-nowrap"
data-testid="github-url-error"
>
{githubUrlError}
</div>
)}
</div>
);
}
return (
<RepositorySelector
options={headerRepositoryOptions}
value={repositoryId || discoveredRepoPath}
onValueChange={onRepositoryChange}
placeholder={getRepositoryPlaceholder(workspaceId, repositoriesLoading, discoverReposLoading)}
searchPlaceholder="Search repositories..."
emptyMessage={
repositoriesLoading || discoverReposLoading
? "Loading repositories..."
: "No repositories found."
}
disabled={isTaskStarted || !workspaceId || repositoriesLoading || discoverReposLoading}
triggerClassName="w-auto text-sm"
/>
);
}
function DialogHeaderContent(props: DialogHeaderContentProps) {
const { isCreateMode, isEditMode, isTaskStarted, sessionRepoName, initialTitle } = props;
const { taskName, onTaskNameChange, useGitHubUrl, onToggleGitHubUrl } = props;
if (isCreateMode || isEditMode) {
return (
<>
<DialogTitle asChild>
<div className="flex items-center gap-1 text-sm font-medium min-w-0">
<RepoSourceInput {...props} />
<span className="text-muted-foreground mr-2">/</span>
<InlineTaskName
value={taskName}
onChange={onTaskNameChange}
autoFocus={!isEditMode && !useGitHubUrl}
/>
</div>
</DialogTitle>
{!isTaskStarted && (
<div className="flex items-center gap-2 pl-2">
<button
type="button"
onClick={onToggleGitHubUrl}
className="text-xs text-muted-foreground hover:text-foreground cursor-pointer transition-colors"
data-testid="toggle-github-url"
>
{useGitHubUrl ? "or select a repository" : "or paste a GitHub URL"}
</button>
</div>
)}
</>
);
}
return (
<DialogTitle asChild>
<div className="flex items-center gap-1 min-w-0 text-sm font-medium">
{sessionRepoName && (
<>
<span className="truncate text-muted-foreground">{sessionRepoName}</span>
<span className="text-muted-foreground mx-0.5">/</span>
</>
)}
<span className="truncate">{initialTitle || "Task"}</span>
<span className="text-muted-foreground mx-0.5">/</span>
<span className="text-muted-foreground whitespace-nowrap">new session</span>
</div>
</DialogTitle>
);
}
type DialogFormBodyProps = {
isSessionMode: boolean;
isCreateMode: boolean;
isTaskStarted: boolean;
isPassthroughProfile: boolean;
initialDescription: string;
hasDescription: boolean;
workspaceId: string | null;
onJiraImport?: (ticket: JiraTicket) => void;
branchOptions: ReturnType<typeof useBranchOptions>;
branchesLoading: boolean;
agentProfileOptions: ReturnType<typeof useAgentProfileOptions>;
executorProfileOptions: Array<{
value: string;
label: string;
renderLabel?: () => React.ReactNode;
}>;
agentProfiles: AgentProfileOption[];
agentProfilesLoading: boolean;
executorsLoading: boolean;
isCreatingSession: boolean;
workflows: unknown[];
snapshots: unknown;
effectiveWorkflowId: string | null;
fs: DialogFormState;
handleKeyDown: ReturnType<typeof useKeyboardShortcutHandler>;
onBranchChange: (v: string) => void;
onAgentProfileChange: (v: string) => void;
onExecutorProfileChange: (v: string) => void;
onWorkflowChange: (v: string) => void;
hasRepositorySelection: boolean;
isLocalExecutor: boolean;
enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean };
workflowAgentLocked: boolean;
};
function DialogFormBody({
isSessionMode,
isCreateMode,
isTaskStarted,
isPassthroughProfile,
initialDescription,
hasDescription,
workspaceId,
onJiraImport,
branchOptions,
branchesLoading,
agentProfileOptions,
executorProfileOptions,
agentProfiles,
agentProfilesLoading,
executorsLoading,
isCreatingSession,
workflows,
snapshots,
effectiveWorkflowId,
fs,
handleKeyDown,
onBranchChange,
onAgentProfileChange,
onExecutorProfileChange,
onWorkflowChange,
hasRepositorySelection,
isLocalExecutor,
enhance,
workflowAgentLocked,
}: DialogFormBodyProps) {
return (
<div className="flex-1 space-y-4 overflow-y-auto pr-1">
<DialogPromptSection
isSessionMode={isSessionMode}
isTaskStarted={isTaskStarted}
isPassthroughProfile={isPassthroughProfile}
initialDescription={initialDescription}
hasDescription={hasDescription}
fs={fs}
handleKeyDown={handleKeyDown}
enhance={enhance}
workspaceId={workspaceId}
onJiraImport={onJiraImport}
/>
{!isSessionMode && (
<CreateEditSelectors
isTaskStarted={isTaskStarted}
hasRepositorySelection={hasRepositorySelection}
branchOptions={branchOptions}
branch={fs.branch}
onBranchChange={onBranchChange}
branchesLoading={branchesLoading}
localBranchesLoading={fs.localBranchesLoading}
agentProfiles={agentProfiles}
agentProfilesLoading={agentProfilesLoading}
agentProfileOptions={agentProfileOptions}
agentProfileId={fs.agentProfileId}
onAgentProfileChange={onAgentProfileChange}
isCreatingSession={isCreatingSession}
executorProfileOptions={executorProfileOptions}
executorProfileId={fs.executorProfileId}
onExecutorProfileChange={onExecutorProfileChange}
executorsLoading={executorsLoading}
isLocalExecutor={isLocalExecutor}
useGitHubUrl={fs.useGitHubUrl}
workflowAgentLocked={workflowAgentLocked}
BranchSelectorComponent={BranchSelector}
AgentSelectorComponent={AgentSelector}
ExecutorProfileSelectorComponent={ExecutorProfileSelector}
/>
)}
<WorkflowSection
isCreateMode={isCreateMode}
isTaskStarted={isTaskStarted}
workflows={workflows as Parameters<typeof WorkflowSection>[0]["workflows"]}
snapshots={snapshots as Parameters<typeof WorkflowSection>[0]["snapshots"]}
effectiveWorkflowId={effectiveWorkflowId}
onWorkflowChange={onWorkflowChange}
agentProfiles={agentProfiles}
/>
{isSessionMode && (
<SessionSelectors
agentProfileOptions={agentProfileOptions}
agentProfileId={fs.agentProfileId}
onAgentProfileChange={onAgentProfileChange}
agentProfilesLoading={agentProfilesLoading}
isCreatingSession={isCreatingSession}
executorProfileOptions={executorProfileOptions}
executorProfileId={fs.executorProfileId}
onExecutorProfileChange={onExecutorProfileChange}
executorsLoading={executorsLoading}
AgentSelectorComponent={AgentSelector}
ExecutorProfileSelectorComponent={ExecutorProfileSelector}
/>
)}
</div>
);
}
function useEnhanceForDialog(fs: DialogFormState) {
const isConfigured = useIsUtilityConfigured();
const { enhancePrompt, isEnhancingPrompt } = useUtilityAgentGenerator({
sessionId: null,
taskTitle: fs.taskName,
});
const onEnhance = useCallback(() => {
const current = fs.descriptionInputRef.current?.getValue()?.trim();
if (!current) return;
enhancePrompt(current, (enhanced) => {
fs.descriptionInputRef.current?.setValue(enhanced);
fs.setHasDescription(true);
});
}, [enhancePrompt, fs]);
return { onEnhance, isLoading: isEnhancingPrompt, isConfigured };
}
function useJiraImportHandler(fs: DialogFormState) {
return useCallback(
(ticket: JiraTicket) => {
const title = `[${ticket.key}] ${ticket.summary}`;
fs.setTaskName(title);
fs.setHasTitle(true);
const description = ticket.description?.trim()
? `${ticket.description}\n\n---\nJira: ${ticket.url}`
: `Jira: ${ticket.url}`;
fs.descriptionInputRef.current?.setValue(description);
fs.setHasDescription(true);
},
[fs],
);
}
function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
const { open, onOpenChange, mode = "create", workspaceId, workflowId, defaultStepId } = props;
const { editingTask, onSuccess, onCreateSession, initialValues, parentTaskId } = props;
const taskId = props.taskId ?? null;
const isSessionMode = mode === "session";
const isEditMode = mode === "edit";
const isCreateMode = mode === "create";
const isTaskStarted = computeIsTaskStarted(isEditMode, editingTask);
const fs = useDialogFormState(open, workspaceId, workflowId, initialValues);
const { toast } = useToast();
const sessionRepoName = useSessionRepoName(isSessionMode);
const {
workflows,
agentProfiles,
executors,
snapshots,
repositories,
repositoriesLoading,
branches,
branchesLoading,
computed,
} = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs);
useTaskCreateDialogEffects(fs, {
open,
workspaceId,
workflowId,
repositories,
repositoriesLoading,
branches,
agentProfiles,
executors,
workspaceDefaults: computed.workspaceDefaults,
toast,
workflows,
});
const handlers = useDialogHandlers(fs, repositories);
const submitHandlers = useTaskSubmitHandlers({
isSessionMode,
isEditMode,
isPassthroughProfile: computed.isPassthroughProfile,
taskName: fs.taskName,
workspaceId,
workflowId,
effectiveWorkflowId: computed.effectiveWorkflowId,
effectiveDefaultStepId: computed.effectiveDefaultStepId,
repositoryId: fs.repositoryId,
selectedLocalRepo: fs.selectedLocalRepo,
useGitHubUrl: fs.useGitHubUrl,
githubUrl: fs.githubUrl,
githubPrHeadBranch: fs.githubPrHeadBranch,
branch: fs.branch,
agentProfileId: computed.effectiveAgentProfileId,
executorId: fs.executorId,
executorProfileId: fs.executorProfileId,
editingTask,
onSuccess,
onCreateSession,
onOpenChange,
taskId,
parentTaskId,
descriptionInputRef: fs.descriptionInputRef,
setIsCreatingSession: fs.setIsCreatingSession,
setIsCreatingTask: fs.setIsCreatingTask,
setHasTitle: fs.setHasTitle,
setHasDescription: fs.setHasDescription,
setTaskName: fs.setTaskName,
setRepositoryId: fs.setRepositoryId,
setBranch: fs.setBranch,
setAgentProfileId: fs.setAgentProfileId,
setExecutorId: fs.setExecutorId,
setSelectedWorkflowId: fs.setSelectedWorkflowId,
setFetchedSteps: fs.setFetchedSteps,
clearDraft: fs.clearDraft,
});
const handleKeyDown = useKeyboardShortcutHandler(SHORTCUTS.SUBMIT, (event) => {
submitHandlers.handleSubmit(event as unknown as FormEvent);
});
return {
fs,
isSessionMode,
isEditMode,
isCreateMode,
isTaskStarted,
sessionRepoName,
workflows,
agentProfiles,
snapshots,
repositoriesLoading,
branchesLoading,
computed,
handlers,
submitHandlers,
handleKeyDown,
enhance: useEnhanceForDialog(fs),
handleJiraImport: useJiraImportHandler(fs),
};
}
export function TaskCreateDialog(props: TaskCreateDialogProps) {
const { open, onOpenChange, initialValues, workspaceId } = props;
const setup = useTaskCreateDialogSetup(props);
const { fs, isSessionMode, isEditMode, isCreateMode, isTaskStarted } = setup;
const { sessionRepoName, workflows, agentProfiles, snapshots } = setup;
const { repositoriesLoading, branchesLoading, computed, handlers, handleKeyDown } = setup;
const { handleSubmit, handleUpdateWithoutAgent, handleCreateWithoutAgent } = setup.submitHandlers;
const { handleCreateWithPlanMode, handleCancel } = setup.submitHandlers;
const { handleJiraImport } = setup;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-testid="create-task-dialog"
className="w-full h-full max-w-full max-h-full rounded-none sm:w-[900px] sm:h-auto sm:max-w-none sm:max-h-[85vh] sm:rounded-lg flex flex-col"
>
<DialogHeader>
<DialogHeaderContent
isCreateMode={isCreateMode}
isEditMode={isEditMode}
isTaskStarted={isTaskStarted}
sessionRepoName={sessionRepoName}
initialTitle={initialValues?.title}
taskName={fs.taskName}
repositoryId={fs.repositoryId}
discoveredRepoPath={fs.discoveredRepoPath}
workspaceId={workspaceId}
repositoriesLoading={repositoriesLoading}
discoverReposLoading={fs.discoverReposLoading}
headerRepositoryOptions={computed.headerRepositoryOptions}
onRepositoryChange={handlers.handleRepositoryChange}
onTaskNameChange={handlers.handleTaskNameChange}
useGitHubUrl={fs.useGitHubUrl}
githubUrl={fs.githubUrl}
githubUrlError={fs.githubUrlError}
onToggleGitHubUrl={handlers.handleToggleGitHubUrl}
onGitHubUrlChange={handlers.handleGitHubUrlChange}
/>
</DialogHeader>
<form onSubmit={handleSubmit} className="flex flex-col gap-4 overflow-hidden">
<DialogFormBody
isSessionMode={isSessionMode}
isCreateMode={isCreateMode}
isTaskStarted={isTaskStarted}
isPassthroughProfile={computed.isPassthroughProfile}
initialDescription={fs.currentDefaults.description}
hasDescription={fs.hasDescription}
workspaceId={workspaceId}
onJiraImport={handleJiraImport}
branchOptions={computed.branchOptions}
branchesLoading={branchesLoading || (fs.useGitHubUrl && fs.githubBranchesLoading)}
agentProfileOptions={computed.agentProfileOptions}
executorProfileOptions={computed.executorProfileOptions}
agentProfiles={agentProfiles}
agentProfilesLoading={computed.agentProfilesLoading}
executorsLoading={computed.executorsLoading}
isCreatingSession={fs.isCreatingSession}
workflows={workflows}
snapshots={snapshots}
effectiveWorkflowId={computed.effectiveWorkflowId ?? null}
fs={fs}
handleKeyDown={handleKeyDown}
onBranchChange={handlers.handleBranchChange}
onAgentProfileChange={handlers.handleAgentProfileChange}
onExecutorProfileChange={handlers.handleExecutorProfileChange}
onWorkflowChange={handlers.handleWorkflowChange}
hasRepositorySelection={computed.hasRepositorySelection}
isLocalExecutor={computed.isLocalExecutor}
enhance={setup.enhance}
workflowAgentLocked={computed.workflowAgentLocked}
/>
<DialogFooter className="border-t border-border pt-3 flex-col gap-3 sm:flex-row sm:gap-2">
<TaskCreateDialogFooter
isSessionMode={isSessionMode}
isCreateMode={isCreateMode}
isEditMode={isEditMode}
isTaskStarted={isTaskStarted}
isPassthroughProfile={computed.isPassthroughProfile}
isCreatingSession={fs.isCreatingSession}
isCreatingTask={fs.isCreatingTask}
hasTitle={fs.hasTitle}
hasDescription={fs.hasDescription}
hasRepositorySelection={computed.hasRepositorySelection}
branch={fs.branch}
agentProfileId={computed.effectiveAgentProfileId}
workspaceId={workspaceId}
effectiveWorkflowId={computed.effectiveWorkflowId ?? null}
executorHint={computed.executorHint}
onCancel={handleCancel}
onUpdateWithoutAgent={handleUpdateWithoutAgent}
onCreateWithoutAgent={handleCreateWithoutAgent}
onCreateWithPlanMode={handleCreateWithPlanMode}
/>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}