45a46f9899
* feat: support tasks without a repository Lets users create a task with no repository attached — useful for research, brainstorming, design discussions, or pointing the agent at an existing non-git folder. The create-task dialog gains a 3-mode segmented control (Repo / URL / None); "None" mode replaces the chip row with a folder picker that browses the host filesystem via a new GET /api/v1/fs/list-dir endpoint and either commits an absolute path or falls back to a kandev- managed scratch workspace at <homeDir>/tasks/<workspaceID>/<taskID>/. The worktree executor is hidden from the picker in no-repo mode (it inherently needs a repo); the lifecycle manager also short-circuits worktree mode server-side as a belt-and-suspenders guard. The Changes panel auto-closes for repo-less tasks since it has nothing to show, and the cumulative-diff handler returns null instead of erroring when no base commit exists. Spec + plan added at docs/specs/tasks-without-repositories/. * fix(backend): anchor folder picker to discovery roots (CodeQL path traversal) CodeQL flagged the new GET /api/v1/fs/list-dir handler for piping a user-controlled path straight into os.Stat / os.ReadDir. Restrict the listing to paths inside the configured discovery roots (defaults to $HOME, same allowlist as repository discovery), reject anything outside with ErrPathNotAllowed → HTTP 403, and clip the breadcrumb's "up" button at the root so the picker can't be used to walk above the allowed prefix. Tests cover: rejection of paths outside roots, traversal escapes (../), default-to-first-root, and parent exposure for nested paths. * fix(backend): use os.Root for folder picker (recognized CodeQL sanitizer) Previous attempt anchored to discovery roots via isPathAllowed, but CodeQL's data flow doesn't model that helper as a sanitizer and kept flagging os.Stat / os.ReadDir. Switch to os.Root (Go 1.24+): open the matching discovery root once and run Stat/Open/ReadDir against the relative path within it. The OS itself enforces containment — symlinks and traversal cannot escape — and CodeQL recognizes os.Root as safe. * fix: address PR review feedback - Changes panel auto-close now distinguishes "task loading" from "task has no repos" (returns boolean | null), so the panel isn't permanently removed during the first-render window before task data lands in the store. - Reset noRepository / workspacePath in resetDiscoveryState so reopening the dialog after a None-mode task doesn't carry the flag forward. - Folder-picker breadcrumb: dropped dead `i > 0 && i > 1` condition (always equivalent to `i > 1`); chevrons now render between every segment as expected. - list-dir HTTP handler returns a generic error message instead of forwarding raw OS errors that leak host paths. - Document the dataDir → kandevHome inversion convention next to the scratch-path computation so future readers don't mistake the filepath.Dir call for a fragile derivation. * fix: address CodeRabbit review nitpicks - Trim whitespace on workspace_path before persisting to task metadata so a value of " " doesn't get stored as a non-empty path. - Extract the workspace_path metadata key into models.MetaKeyWorkspacePath alongside the existing MetaKeyAgentProfileID / MetaKeyExecutorProfileID so the set/read sites can't drift apart. - Folder picker: reset cached listing when the popover closes, so reopening reloads from `value` instead of showing the last browsed directory the user navigated away from without committing. - Submit payload: send `workspace_path: undefined` instead of an empty string when the trimmed value is empty, matching the noRepository=false case and keeping the API contract clear. * fix: scratch path lands in kandev home, not its parent Claude caught a real bug — `m.dataDir` is set from `cfg.ResolvedHomeDir()` in cmd/kandev/agents.go, not `ResolvedDataDir()` (the field name is misleading and the comment was wrong). My previous use of `filepath.Dir(m.dataDir)` therefore walked one level above the kandev home, so scratch dirs landed at e.g. `~/tasks/<ws>/<task>` instead of `~/.kandev/tasks/<ws>/<task>` — and at the project root in dev mode where `KANDEV_HOME_DIR=<project>/.kandev-dev`, which is what the user saw on their machine. Use `m.dataDir` directly (it's already the kandev home), document the field's misnamed-but-correct semantics, and tighten the test fixtures. Also: - folder-picker trigger gets `cursor-pointer` (CodeRabbit nitpick). * fix: pass workspacePath through handleCreateWithoutAgent too The "create without agent" path called buildCreateTaskPayload without the new workspacePath field, so picking a folder + clicking "Create without starting" silently dropped the path. Now matches the performCreate (with-agent) path. * fix: satisfy DialogFormState in new-subtask form stub PR #852 (merged after this branch was created) introduced new-subtask-form-state.ts which stubs out a DialogFormState for the subtask dialog. Adding noRepository / setNoRepository / workspacePath / setWorkspacePath to the type made that stub incomplete, breaking the Next.js production build (caught by CI, missed by tsc --noEmit). Add the four inert fields to the stub, alongside the existing fresh- branch inert fields, and pull both groups into named constants to keep the useMemo body under the per-function line cap. * fix: keep toggle-github-url testid as alias on URL-mode button The new source-mode segmented control replaced the previous "or paste a GitHub URL" text link, which had data-testid="toggle-github-url". The existing e2e suite (create-task-github-url.spec.ts and the new subtask.spec.ts from #852) still selects the URL toggle by that testid. Keep "toggle-github-url" as the testid on the URL-mode button so both suites pass without a sweep across all e2e files. Click semantics are unchanged — single click enters URL mode. * test(e2e): update toggle assertion for new source-mode segmented control create-task-github-url.spec.ts:892 was checking the old text-link toggle's two-way behavior with text labels ("or paste a GitHub URL" / "use a workspace repository"). The new segmented control has explicit mode buttons; assert aria-checked instead of label text. The other 9 clicks on `toggle-github-url` in this file still work via the legacy testid alias on the URL-mode button. * refactor: extract useActiveTaskHasRepos to its own hook file dockview-desktop-layout.tsx grew to 609 lines with the new hook + the Changes panel guard, over the 600-line cap documented in CLAUDE.md (lint passed but the convention is real). Move the hook to hooks/domains/kanban/use-active-task-has-repos.ts. File drops to 590 lines. * chore: gitignore .claude/scheduled_tasks.lock runtime file * fix(web): lock chip-row min-height so modal doesn't shift on mode toggle The chip row uses flex-wrap; Repo mode (chip + branch + add button + segmented control) sometimes occupies a different vertical extent than None mode (single folder picker + segmented control), causing the create-task modal to jump a few pixels when toggling between sources. Set min-h-9 so the row always reserves at least one chip-row of height regardless of the active mode. * feat: let folder picker browse anywhere, drop allowed-roots gate The picker is for choosing a starting folder for a repo-less task — users legitimately want /tmp, /var/log/foo, or any directory they have read access to. Coupling it to the repo-discover endpoint's discoveryRoots ($HOME by default) was too tight: paths like /tmp returned 403 "path is not within allowed roots". Decouple ListDirectory from discoveryRoots. The repo-discover endpoint (DiscoverLocalRepositories, ValidateLocalRepositoryPath, LocalRepositoryStatus, ListBranches) stays anchored to the allowlist since picking a repo to import is a workspace-scoped operation; the folder picker trusts the local-process boundary instead. CodeQL is kept happy by routing the FS operations through os.Root opened at "/" — the Go stdlib sanitizer for path injection still recognises it (containment to / is trivially satisfied for every absolute path on the host). --------- Co-authored-by: Kandev Agent <agent@kandev.dev>
293 lines
9.5 KiB
TypeScript
293 lines
9.5 KiB
TypeScript
"use client";
|
|
|
|
import { FormEvent, useCallback } from "react";
|
|
import type { JiraTicket } from "@/lib/types/jira";
|
|
import type { LinearIssue } from "@/lib/types/linear";
|
|
import type { Task } from "@/lib/types/http";
|
|
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 { useTaskSubmitHandlers } from "@/components/task-create-dialog-submit";
|
|
import { useToast } from "@/components/toast-provider";
|
|
import {
|
|
useDialogFormState,
|
|
useTaskCreateDialogEffects,
|
|
useDialogHandlers,
|
|
useLockedFieldSync,
|
|
useSessionRepoName,
|
|
useTaskCreateDialogData,
|
|
computeIsTaskStarted,
|
|
type DialogFormState,
|
|
type TaskCreateDialogInitialValues,
|
|
} from "@/components/task-create-dialog-state";
|
|
|
|
export 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;
|
|
/**
|
|
* Optional UI rendered below the description textarea. Used by feature wrappers
|
|
* such as Improve Kandev to add capture toggles and pre-flight status without
|
|
* forking the dialog.
|
|
*/
|
|
extraFormSlot?: React.ReactNode;
|
|
/**
|
|
* Optional async transform applied to the task description before the API
|
|
* payload is built. Awaited; receives the trimmed description and returns
|
|
* the value to send. Used to append context-bundle file paths.
|
|
*/
|
|
transformDescriptionBeforeSubmit?: (description: string) => Promise<string> | string;
|
|
/**
|
|
* Optional locks for individual fields. When a flag is true, the
|
|
* corresponding selector is disabled (or hidden, for workflow). Used by
|
|
* feature wrappers such as Improve Kandev to enforce a fixed
|
|
* repository / branch / workflow selection.
|
|
*/
|
|
lockedFields?: { repository?: boolean; branch?: boolean; workflow?: boolean };
|
|
/**
|
|
* Optional override for the task description textarea placeholder. Used by
|
|
* feature wrappers (e.g. Improve Kandev) to suggest the kind of input
|
|
* expected.
|
|
*/
|
|
descriptionPlaceholder?: string;
|
|
/**
|
|
* Optional slot rendered above the description textarea (e.g. a bug/feature
|
|
* tab toggle for Improve Kandev).
|
|
*/
|
|
aboveDescriptionSlot?: React.ReactNode;
|
|
/**
|
|
* Optional slot rendered below the branch/agent/executor selectors (e.g.
|
|
* a workflow preview or contextual help for Improve Kandev).
|
|
*/
|
|
bottomSlot?: React.ReactNode;
|
|
}
|
|
|
|
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 useLinearImportHandler(fs: DialogFormState) {
|
|
return useCallback(
|
|
(issue: LinearIssue) => {
|
|
const title = `[${issue.identifier}] ${issue.title}`;
|
|
fs.setTaskName(title);
|
|
fs.setHasTitle(true);
|
|
const description = issue.description?.trim()
|
|
? `${issue.description}\n\n---\nLinear: ${issue.url}`
|
|
: `Linear: ${issue.url}`;
|
|
fs.descriptionInputRef.current?.setValue(description);
|
|
fs.setHasDescription(true);
|
|
},
|
|
[fs],
|
|
);
|
|
}
|
|
|
|
type SubmitWiringArgs = {
|
|
props: TaskCreateDialogProps;
|
|
fs: ReturnType<typeof useDialogFormState>;
|
|
computed: ReturnType<typeof useTaskCreateDialogData>["computed"];
|
|
workspaceRepositories: ReturnType<typeof useTaskCreateDialogData>["repositories"];
|
|
repositoryLocalPath: string;
|
|
isSessionMode: boolean;
|
|
isEditMode: boolean;
|
|
};
|
|
|
|
function useSubmitHandlersWiring({
|
|
props,
|
|
fs,
|
|
computed,
|
|
workspaceRepositories,
|
|
repositoryLocalPath,
|
|
isSessionMode,
|
|
isEditMode,
|
|
}: SubmitWiringArgs) {
|
|
const { workspaceId, workflowId, editingTask, onSuccess, onCreateSession, onOpenChange } = props;
|
|
const { parentTaskId } = props;
|
|
const taskId = props.taskId ?? null;
|
|
return useTaskSubmitHandlers({
|
|
isSessionMode,
|
|
isEditMode,
|
|
isPassthroughProfile: computed.isPassthroughProfile,
|
|
taskName: fs.taskName,
|
|
workspaceId,
|
|
workflowId,
|
|
effectiveWorkflowId: computed.effectiveWorkflowId,
|
|
effectiveDefaultStepId: computed.effectiveDefaultStepId,
|
|
repositories: fs.repositories,
|
|
discoveredRepositories: fs.discoveredRepositories,
|
|
workspaceRepositories,
|
|
useGitHubUrl: fs.useGitHubUrl,
|
|
githubUrl: fs.githubUrl,
|
|
githubPrHeadBranch: fs.githubPrHeadBranch,
|
|
githubBranch: fs.githubBranch,
|
|
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,
|
|
setRepositories: fs.setRepositories,
|
|
setGitHubBranch: fs.setGitHubBranch,
|
|
setAgentProfileId: fs.setAgentProfileId,
|
|
setExecutorId: fs.setExecutorId,
|
|
setSelectedWorkflowId: fs.setSelectedWorkflowId,
|
|
setFetchedSteps: fs.setFetchedSteps,
|
|
clearDraft: fs.clearDraft,
|
|
freshBranchEnabled: fs.freshBranchEnabled,
|
|
isLocalExecutor: computed.isLocalExecutor,
|
|
repositoryLocalPath,
|
|
noRepository: fs.noRepository,
|
|
workspacePath: fs.workspacePath,
|
|
transformDescriptionBeforeSubmit: props.transformDescriptionBeforeSubmit,
|
|
});
|
|
}
|
|
|
|
export function useTaskCreateDialogSetup(props: TaskCreateDialogProps) {
|
|
const { open, mode = "create", workspaceId, workflowId, defaultStepId } = props;
|
|
const { editingTask, initialValues } = props;
|
|
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,
|
|
branchesLoading,
|
|
computed,
|
|
} = useTaskCreateDialogData(open, workspaceId, workflowId, defaultStepId, fs);
|
|
// Multi-repo: the "repo local path" used by the fresh-branch preflight is
|
|
// the first chip's path. Empty when no chip has a repo selected (URL mode
|
|
// or empty rows). Discovered (path-only) chips short-circuit the lookup.
|
|
const repositoryLocalPath = (() => {
|
|
const first = fs.repositories[0];
|
|
if (!first) return "";
|
|
if (first.localPath) return first.localPath;
|
|
if (first.repositoryId) {
|
|
const repo = repositories.find((r) => r.id === first.repositoryId);
|
|
return repo?.local_path ?? "";
|
|
}
|
|
return "";
|
|
})();
|
|
useTaskCreateDialogEffects(fs, {
|
|
open,
|
|
workspaceId,
|
|
workflowId,
|
|
repositories,
|
|
repositoriesLoading,
|
|
agentProfiles,
|
|
executors,
|
|
workspaceDefaults: computed.workspaceDefaults,
|
|
toast,
|
|
workflows,
|
|
isLocalExecutor: computed.isLocalExecutor,
|
|
});
|
|
useLockedFieldSync(open, workflowId, initialValues, fs);
|
|
const handlers = useDialogHandlers(fs, repositories);
|
|
const submitHandlers = useSubmitHandlersWiring({
|
|
props,
|
|
fs,
|
|
computed,
|
|
workspaceRepositories: repositories,
|
|
repositoryLocalPath,
|
|
isSessionMode,
|
|
isEditMode,
|
|
});
|
|
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),
|
|
handleLinearImport: useLinearImportHandler(fs),
|
|
};
|
|
}
|