feat: support tasks without a repository (#850)
* 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>
This commit is contained in:
@@ -54,6 +54,12 @@ temp/
|
||||
# production ~/.kandev when developing kandev using kandev.
|
||||
/.kandev-dev/
|
||||
|
||||
# Runtime scratch workspaces created for repo-less tasks. The lifecycle
|
||||
# manager places them at <homeDir>/tasks/<workspaceID>/<taskID>; in dev mode
|
||||
# (homeDir = .kandev-dev/) they're already covered above, but a misconfigured
|
||||
# homeDir could land them at the project root.
|
||||
/tasks/
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
@@ -103,3 +109,4 @@ normalized-*.jsonl
|
||||
|
||||
# playwright-cli snapshots/console logs
|
||||
.playwright-cli/
|
||||
/.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -66,15 +66,22 @@ func newLifecycleAdapter(mgr *lifecycle.Manager, reg *registry.Registry, log *lo
|
||||
// LaunchAgent creates a new agentctl instance for a task.
|
||||
// Agent subprocess is NOT started - call StartAgentProcess() explicitly.
|
||||
func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.LaunchAgentRequest) (*executor.LaunchAgentResponse, error) {
|
||||
// The RepositoryURL field contains a local filesystem path for the workspace
|
||||
// If empty, the agent will run without a mounted workspace
|
||||
// WorkspacePath wins when set (repo-less task with picked folder); otherwise
|
||||
// fall back to RepositoryURL (legacy: this carries a local filesystem path
|
||||
// for the workspace). Empty is also valid — lifecycle manager creates a
|
||||
// scratch workspace.
|
||||
workspacePath := req.WorkspacePath
|
||||
if workspacePath == "" {
|
||||
workspacePath = req.RepositoryURL
|
||||
}
|
||||
launchReq := &lifecycle.LaunchRequest{
|
||||
TaskID: req.TaskID,
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
SessionID: req.SessionID,
|
||||
TaskEnvironmentID: req.TaskEnvironmentID,
|
||||
TaskTitle: req.TaskTitle,
|
||||
AgentProfileID: req.AgentProfileID,
|
||||
WorkspacePath: req.RepositoryURL, // May be empty - lifecycle manager handles this
|
||||
WorkspacePath: workspacePath,
|
||||
TaskDescription: req.TaskDescription,
|
||||
Attachments: convertToLifecycleAttachments(req.Attachments),
|
||||
Env: req.Env,
|
||||
|
||||
@@ -802,7 +802,12 @@ func (h *GitHandlers) wsCumulativeDiff(ctx context.Context, msg *ws.Message) (*w
|
||||
zap.String("session_id", req.SessionID),
|
||||
zap.String("base_commit", baseCommit))
|
||||
} else {
|
||||
return nil, fmt.Errorf("no base commit available for cumulative diff")
|
||||
// Repo-less tasks (no git workspace) have no base commit — return an
|
||||
// empty diff instead of erroring so the frontend's polling doesn't
|
||||
// spam the logs.
|
||||
return ws.NewResponse(msg.ID, msg.Action, map[string]any{
|
||||
"cumulative_diff": nil,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,10 +41,12 @@ type Manager struct {
|
||||
worktreeMgr *worktree.Manager
|
||||
mcpProvider McpConfigProvider
|
||||
logger *logger.Logger
|
||||
// dataDir is the base data directory for Kandev (from config.ResolvedDataDir()).
|
||||
// Used for:
|
||||
// dataDir is the kandev root directory. Misnamed for historical reasons:
|
||||
// cmd/kandev/agents.go passes cfg.ResolvedHomeDir() (the kandev root —
|
||||
// typically ~/.kandev) here, not ResolvedDataDir(). Used for:
|
||||
// - Session history storage (SessionHistoryManager)
|
||||
// - Ephemeral workspace creation (quick chat, tasks without repositories)
|
||||
// - Ephemeral workspace creation (quick chat) at <root>/quick-chat/<sessionID>
|
||||
// - Scratch workspaces for repo-less tasks at <root>/tasks/<workspaceID>/<taskID>
|
||||
dataDir string
|
||||
|
||||
// ExecutorRegistry manages multiple runtimes (Docker, Standalone, etc.)
|
||||
|
||||
@@ -153,10 +153,13 @@ func (m *Manager) resolveWorkspaceFromProvider(ctx context.Context, req *LaunchR
|
||||
}
|
||||
|
||||
func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchRequest) (workspacePath, mainRepoGitDir, worktreeID, worktreeBranch string) {
|
||||
if req.UseWorktree && req.ACPSessionID == "" {
|
||||
// Worktree mode requires a repository. Repo-less tasks fall through to the
|
||||
// scratch workspace path below — even if the executor type was worktree.
|
||||
useWorktree := req.UseWorktree && req.RepositoryPath != ""
|
||||
if useWorktree && req.ACPSessionID == "" {
|
||||
return "", "", "", ""
|
||||
}
|
||||
if req.UseWorktree && req.ACPSessionID != "" {
|
||||
if useWorktree && req.ACPSessionID != "" {
|
||||
return m.resolveResumeWorktreePath(ctx, req)
|
||||
}
|
||||
workspacePath = req.WorkspacePath
|
||||
@@ -168,55 +171,78 @@ func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchReq
|
||||
return resolved, "", "", ""
|
||||
}
|
||||
}
|
||||
// For ephemeral tasks without repositories (e.g., quick chat), create a workspace in ~/.kandev/quick-chat/
|
||||
// These directories are cleaned up when the ephemeral task is deleted (see task service performTaskCleanup).
|
||||
// Non-ephemeral tasks should not receive a fallback workspace — they should fail loudly
|
||||
// if no repository is configured (the MCP handler validates this for auto-start tasks).
|
||||
// For tasks without a repository, create a scratch workspace.
|
||||
// - Non-ephemeral repo-less tasks: <homeDir>/tasks/<workspaceID>/<taskID>/
|
||||
// (task-scoped, persists across sessions, mirrors the worktree task layout).
|
||||
// - Ephemeral tasks (slack triage / quick chat): <dataDir>/quick-chat/<sessionID>/
|
||||
// (session-scoped, cleaned up on task delete via performTaskCleanup).
|
||||
if workspacePath == "" && req.SessionID != "" && m.dataDir != "" {
|
||||
if !req.IsEphemeral {
|
||||
m.logger.Warn("non-ephemeral task has no workspace path; skipping quick-chat fallback",
|
||||
zap.String("task_id", req.TaskID),
|
||||
zap.String("session_id", req.SessionID))
|
||||
return "", "", "", ""
|
||||
}
|
||||
quickChatDir := filepath.Join(m.dataDir, "quick-chat")
|
||||
if err := os.MkdirAll(quickChatDir, 0755); err != nil {
|
||||
m.logger.Warn("failed to create quick-chat directory, continuing without workspace",
|
||||
zap.String("session_id", req.SessionID),
|
||||
zap.String("quick_chat_dir", quickChatDir),
|
||||
zap.Error(err))
|
||||
return "", "", "", ""
|
||||
}
|
||||
// Validate SessionID doesn't contain path separators (security: prevent path traversal)
|
||||
workspacePath = m.resolveScratchWorkspace(ctx, req)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// resolveScratchWorkspace creates and returns the scratch workspace path for a
|
||||
// repo-less task. Returns empty string when the path could not be created.
|
||||
func (m *Manager) resolveScratchWorkspace(ctx context.Context, req *LaunchRequest) string {
|
||||
scratchPath := m.scratchWorkspacePath(req)
|
||||
if scratchPath == "" {
|
||||
return ""
|
||||
}
|
||||
if err := os.MkdirAll(scratchPath, 0755); err != nil {
|
||||
m.logger.Warn("failed to create scratch workspace, continuing without workspace",
|
||||
zap.String("session_id", req.SessionID),
|
||||
zap.String("workspace_path", scratchPath),
|
||||
zap.Error(err))
|
||||
return ""
|
||||
}
|
||||
if err := m.initGitRepo(ctx, scratchPath); err != nil {
|
||||
m.logger.Warn("failed to initialize git repository in scratch workspace",
|
||||
zap.String("session_id", req.SessionID),
|
||||
zap.String("workspace_path", scratchPath),
|
||||
zap.Error(err))
|
||||
// Continue anyway - git is optional for repo-less workspaces.
|
||||
}
|
||||
m.logger.Info("created scratch workspace",
|
||||
zap.String("session_id", req.SessionID),
|
||||
zap.String("task_id", req.TaskID),
|
||||
zap.String("workspace_path", scratchPath))
|
||||
return scratchPath
|
||||
}
|
||||
|
||||
// scratchWorkspacePath computes the scratch workspace path for a launch request.
|
||||
// Returns empty string if the inputs are invalid (path traversal guard, missing IDs).
|
||||
func (m *Manager) scratchWorkspacePath(req *LaunchRequest) string {
|
||||
if req.IsEphemeral {
|
||||
// Legacy quick-chat path — session-scoped, kept for backward compat with
|
||||
// slack triage and other ephemeral one-shot flows.
|
||||
if strings.ContainsAny(req.SessionID, `/\`) {
|
||||
m.logger.Warn("session ID contains path separator, rejecting",
|
||||
zap.String("session_id", req.SessionID))
|
||||
return "", "", "", ""
|
||||
return ""
|
||||
}
|
||||
// Use session ID as directory name for easy cleanup
|
||||
workspacePath = filepath.Join(quickChatDir, req.SessionID)
|
||||
if err := os.MkdirAll(workspacePath, 0755); err != nil {
|
||||
m.logger.Warn("failed to create session workspace, continuing without workspace",
|
||||
zap.String("session_id", req.SessionID),
|
||||
zap.String("workspace_path", workspacePath),
|
||||
zap.Error(err))
|
||||
return "", "", "", ""
|
||||
}
|
||||
|
||||
// Initialize git repository in the workspace (if not already initialized)
|
||||
if err := m.initGitRepo(ctx, workspacePath); err != nil {
|
||||
m.logger.Warn("failed to initialize git repository in quick chat workspace",
|
||||
zap.String("session_id", req.SessionID),
|
||||
zap.String("workspace_path", workspacePath),
|
||||
zap.Error(err))
|
||||
// Continue anyway - git is optional for quick chat
|
||||
}
|
||||
|
||||
m.logger.Info("created quick chat workspace",
|
||||
zap.String("session_id", req.SessionID),
|
||||
zap.String("workspace_path", workspacePath))
|
||||
return filepath.Join(m.dataDir, "quick-chat", req.SessionID)
|
||||
}
|
||||
return
|
||||
// Non-ephemeral repo-less task: place under <homeDir>/tasks/<workspaceID>/<taskID>/
|
||||
// so it sits alongside repo-bound worktrees and persists across sessions.
|
||||
if req.TaskID == "" || req.WorkspaceID == "" {
|
||||
m.logger.Warn("scratch workspace requires task_id and workspace_id",
|
||||
zap.String("session_id", req.SessionID),
|
||||
zap.String("task_id", req.TaskID),
|
||||
zap.String("workspace_id", req.WorkspaceID))
|
||||
return ""
|
||||
}
|
||||
if strings.ContainsAny(req.TaskID, `/\`) || strings.ContainsAny(req.WorkspaceID, `/\`) {
|
||||
m.logger.Warn("task or workspace ID contains path separator, rejecting",
|
||||
zap.String("task_id", req.TaskID),
|
||||
zap.String("workspace_id", req.WorkspaceID))
|
||||
return ""
|
||||
}
|
||||
// m.dataDir is misnamed — cmd/kandev/agents.go passes cfg.ResolvedHomeDir()
|
||||
// (the kandev root, e.g. ~/.kandev), not ResolvedDataDir(). So scratch
|
||||
// workspaces live alongside the existing repo-bound worktree task dirs
|
||||
// at <kandevHome>/tasks/<workspaceID>/<taskID>/.
|
||||
return filepath.Join(m.dataDir, "tasks", req.WorkspaceID, req.TaskID)
|
||||
}
|
||||
|
||||
// launchPrepareRequest copies the launch request, sets the resolved workspace path,
|
||||
|
||||
@@ -2,6 +2,7 @@ package lifecycle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -194,17 +195,70 @@ func TestLaunchResolveWorkspacePath_EphemeralCreatesQuickChatDir(t *testing.T) {
|
||||
require.Contains(t, workspacePath, "session-abc")
|
||||
}
|
||||
|
||||
func TestLaunchResolveWorkspacePath_NonEphemeralSkipsQuickChatDir(t *testing.T) {
|
||||
func TestLaunchResolveWorkspacePath_NonEphemeralRepoLessGetsScratchDir(t *testing.T) {
|
||||
mgr := newTestManager()
|
||||
mgr.dataDir = t.TempDir()
|
||||
|
||||
req := &LaunchRequest{
|
||||
SessionID: "session-xyz",
|
||||
TaskID: "task-xyz",
|
||||
WorkspaceID: "ws-xyz",
|
||||
IsEphemeral: false,
|
||||
}
|
||||
|
||||
workspacePath, _, _, _ := mgr.launchResolveWorkspacePath(context.Background(), req)
|
||||
require.Empty(t, workspacePath, "non-ephemeral task without repo should NOT get a quick-chat workspace")
|
||||
require.NotEmpty(t, workspacePath, "non-ephemeral task without repo should still get a scratch workspace")
|
||||
// New layout: <homeDir>/tasks/<workspaceID>/<taskID> (sibling to worktree task dirs).
|
||||
require.Contains(t, workspacePath, filepath.Join("tasks", "ws-xyz", "task-xyz"))
|
||||
require.NotContains(t, workspacePath, "quick-chat")
|
||||
}
|
||||
|
||||
func TestLaunchResolveWorkspacePath_NonEphemeralWithoutWorkspaceIDReturnsEmpty(t *testing.T) {
|
||||
mgr := newTestManager()
|
||||
mgr.dataDir = t.TempDir()
|
||||
|
||||
// Non-ephemeral repo-less task missing workspace_id should not get a path
|
||||
// (scratch path requires workspace + task IDs to namespace correctly).
|
||||
req := &LaunchRequest{
|
||||
SessionID: "session-no-ws",
|
||||
TaskID: "task-1",
|
||||
IsEphemeral: false,
|
||||
}
|
||||
|
||||
workspacePath, _, _, _ := mgr.launchResolveWorkspacePath(context.Background(), req)
|
||||
require.Empty(t, workspacePath)
|
||||
}
|
||||
|
||||
func TestLaunchResolveWorkspacePath_PickedFolderUsedDirectly(t *testing.T) {
|
||||
mgr := newTestManager()
|
||||
mgr.dataDir = t.TempDir()
|
||||
picked := t.TempDir() // some existing folder the user picked
|
||||
|
||||
req := &LaunchRequest{
|
||||
SessionID: "session-pick",
|
||||
WorkspacePath: picked,
|
||||
}
|
||||
|
||||
workspacePath, _, _, _ := mgr.launchResolveWorkspacePath(context.Background(), req)
|
||||
require.Equal(t, picked, workspacePath, "picked workspace_path should be used as-is, not replaced by scratch")
|
||||
}
|
||||
|
||||
func TestLaunchResolveWorkspacePath_WorktreeWithoutRepoFallsBackToScratch(t *testing.T) {
|
||||
mgr := newTestManager()
|
||||
mgr.dataDir = t.TempDir()
|
||||
|
||||
// UseWorktree=true but no RepositoryPath — should not return empty,
|
||||
// should fall through to the scratch workspace path.
|
||||
req := &LaunchRequest{
|
||||
SessionID: "session-wt",
|
||||
TaskID: "task-wt",
|
||||
WorkspaceID: "ws-wt",
|
||||
UseWorktree: true,
|
||||
}
|
||||
|
||||
workspacePath, _, _, _ := mgr.launchResolveWorkspacePath(context.Background(), req)
|
||||
require.NotEmpty(t, workspacePath, "worktree-mode task without repo should fall through to scratch")
|
||||
require.Contains(t, workspacePath, filepath.Join("tasks", "ws-wt", "task-wt"))
|
||||
}
|
||||
|
||||
// TestLaunch_PromotesWorkspaceOnlyExecution verifies that when Launch finds an
|
||||
|
||||
@@ -284,6 +284,7 @@ type RepoLaunchSpec struct {
|
||||
// LaunchRequest contains parameters for launching an agent
|
||||
type LaunchRequest struct {
|
||||
TaskID string
|
||||
WorkspaceID string // Kandev workspace ID — used to build the scratch dir for repo-less tasks
|
||||
SessionID string
|
||||
TaskEnvironmentID string // Env this session belongs to (shared across sessions in same task)
|
||||
TaskTitle string // Human-readable task title for semantic worktree naming
|
||||
|
||||
@@ -229,6 +229,7 @@ type AgentProfileInfo struct {
|
||||
// LaunchAgentRequest contains parameters for launching an agent
|
||||
type LaunchAgentRequest struct {
|
||||
TaskID string
|
||||
WorkspaceID string // Kandev workspace ID — used to build scratch dir for repo-less tasks
|
||||
SessionID string
|
||||
TaskEnvironmentID string // Env owning this session (shared across sessions in the same task)
|
||||
TaskTitle string // Human-readable task title for semantic worktree naming
|
||||
@@ -247,6 +248,7 @@ type LaunchAgentRequest struct {
|
||||
PreviousExecutionID string // Previous execution ID for runtime reconnect
|
||||
McpMode string // MCP tool mode: "config" activates config-mode tools
|
||||
IsEphemeral bool // Ephemeral task (quick chat) — enables fallback workspace creation
|
||||
WorkspacePath string // Optional host folder for repo-less tasks (overrides scratch fallback)
|
||||
|
||||
// Setup script from executor profile (runs in execution environment before agent starts)
|
||||
SetupScript string
|
||||
|
||||
@@ -252,7 +252,9 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi
|
||||
}
|
||||
isFirstSession := !hasPrimary
|
||||
|
||||
// Create agent session in database
|
||||
// Create agent session in database. WorkspacePath is propagated from task
|
||||
// metadata for repo-less tasks where the user picked a starting folder.
|
||||
workspacePath, _ := task.Metadata[models.MetaKeyWorkspacePath].(string)
|
||||
sessionID := uuid.New().String()
|
||||
now := time.Now().UTC()
|
||||
session := &models.TaskSession{
|
||||
@@ -261,6 +263,7 @@ func (e *Executor) PrepareSession(ctx context.Context, task *v1.Task, agentProfi
|
||||
AgentProfileID: agentProfileID,
|
||||
RepositoryID: repositoryID,
|
||||
BaseBranch: baseBranch,
|
||||
WorkspacePath: workspacePath,
|
||||
State: models.TaskSessionStateCreated,
|
||||
StartedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -555,6 +558,7 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s
|
||||
sessionID := session.ID
|
||||
req := &LaunchAgentRequest{
|
||||
TaskID: task.ID,
|
||||
WorkspaceID: task.WorkspaceID,
|
||||
TaskTitle: task.Title,
|
||||
AgentProfileID: agentProfileID,
|
||||
TaskDescription: prompt,
|
||||
@@ -562,6 +566,7 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s
|
||||
SessionID: sessionID,
|
||||
TaskEnvironmentID: session.TaskEnvironmentID,
|
||||
IsEphemeral: task.IsEphemeral,
|
||||
WorkspacePath: session.WorkspacePath,
|
||||
}
|
||||
|
||||
execConfig := e.resolveExecutorConfig(ctx, executorID, task.WorkspaceID, metadata)
|
||||
|
||||
@@ -48,6 +48,7 @@ func (h *RepositoryHandlers) registerHTTP(router *gin.Engine) {
|
||||
// flow on the local executor. Path-only — fresh-branch is local-only.
|
||||
api.GET("/workspaces/:id/repositories/local-status", h.httpLocalRepositoryStatus)
|
||||
api.GET("/workspaces/:id/repositories/validate", h.httpValidateRepositoryPath)
|
||||
api.GET("/fs/list-dir", h.httpListDirectory)
|
||||
api.GET("/repositories/:id", h.httpGetRepository)
|
||||
api.GET("/repositories/:id/branches", h.httpListRepositoryBranches)
|
||||
api.PATCH("/repositories/:id", h.httpUpdateRepository)
|
||||
@@ -136,6 +137,33 @@ func (h *RepositoryHandlers) httpDiscoverRepositories(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// httpListDirectory lists the immediate subdirectories of ?path= (defaults
|
||||
// to $HOME). The picker deliberately allows browsing any directory the
|
||||
// kandev process has read access to — kandev runs locally on the user's
|
||||
// own machine, and the repo-less starting-folder flow legitimately wants
|
||||
// /tmp, /var/log/foo, etc. Hidden (dotfile) directories are excluded.
|
||||
func (h *RepositoryHandlers) httpListDirectory(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
result, err := h.service.ListDirectory(c.Request.Context(), path)
|
||||
if err != nil {
|
||||
// Log the raw OS error for debugging but return a generic message —
|
||||
// otherwise we leak host paths and access patterns to the client (e.g.
|
||||
// "open /home/user/private: permission denied").
|
||||
h.logger.Warn("failed to list directory", zap.String("path", path), zap.Error(err))
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to list directory"})
|
||||
return
|
||||
}
|
||||
entries := make([]gin.H, 0, len(result.Entries))
|
||||
for _, e := range result.Entries {
|
||||
entries = append(entries, gin.H{"name": e.Name, "path": e.Path})
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"path": result.Path,
|
||||
"parent": result.Parent,
|
||||
"entries": entries,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *RepositoryHandlers) httpValidateRepositoryPath(c *gin.Context) {
|
||||
path := c.Query("path")
|
||||
if path == "" {
|
||||
|
||||
@@ -394,6 +394,7 @@ type httpCreateTaskRequest struct {
|
||||
PlanMode bool `json:"plan_mode,omitempty"`
|
||||
Attachments []v1.MessageAttachment `json:"attachments,omitempty"`
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
WorkspacePath string `json:"workspace_path,omitempty"`
|
||||
}
|
||||
|
||||
type createTaskResponse struct {
|
||||
@@ -494,6 +495,7 @@ func (h *TaskHandlers) httpCreateTask(c *gin.Context) {
|
||||
Metadata: body.Metadata,
|
||||
PlanMode: body.PlanMode && !body.StartAgent,
|
||||
ParentID: body.ParentID,
|
||||
WorkspacePath: body.WorkspacePath,
|
||||
})
|
||||
if err != nil {
|
||||
handleNotFound(c, h.logger, err, "task not created")
|
||||
|
||||
@@ -38,6 +38,10 @@ type SearchMessagesOptions struct {
|
||||
const (
|
||||
MetaKeyAgentProfileID = "agent_profile_id"
|
||||
MetaKeyExecutorProfileID = "executor_profile_id"
|
||||
// MetaKeyWorkspacePath is the optional host folder for repo-less tasks
|
||||
// (set by CreateTask, read by the orchestrator when building a session).
|
||||
// Centralised here so the set/read sites can't drift apart.
|
||||
MetaKeyWorkspacePath = "workspace_path"
|
||||
)
|
||||
|
||||
// Task represents a task in the database
|
||||
@@ -281,6 +285,7 @@ type TaskSession struct {
|
||||
RepositoryID string `json:"repository_id"` // Primary repository (for backward compatibility)
|
||||
BaseBranch string `json:"base_branch"` // Primary base branch (for backward compatibility)
|
||||
BaseCommitSHA string `json:"base_commit_sha"` // Git commit SHA at session start (for cumulative diff)
|
||||
WorkspacePath string `json:"workspace_path"` // Optional host folder for repo-less tasks (when user picked a starting folder)
|
||||
Worktrees []*TaskSessionWorktree `json:"worktrees,omitempty"` // Associated worktrees
|
||||
AgentProfileSnapshot map[string]interface{} `json:"agent_profile_snapshot,omitempty"`
|
||||
ExecutorSnapshot map[string]interface{} `json:"executor_snapshot,omitempty"`
|
||||
|
||||
@@ -195,6 +195,9 @@ func (r *Repository) runMigrations() error {
|
||||
_, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN agent_profile_id TEXT DEFAULT ''`)
|
||||
// Add hidden flag to workflows for system-only flows excluded from management UI (ignore error if already exists)
|
||||
_, _ = r.db.Exec(`ALTER TABLE workflows ADD COLUMN hidden INTEGER NOT NULL DEFAULT 0`)
|
||||
// Add workspace_path to task_sessions: optional host folder for repo-less
|
||||
// tasks where the user pointed the agent at an existing directory.
|
||||
_, _ = r.db.Exec(`ALTER TABLE task_sessions ADD COLUMN workspace_path TEXT DEFAULT ''`)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -212,13 +212,13 @@ func (r *Repository) CreateTaskSession(ctx context.Context, session *models.Task
|
||||
_, err = r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
INSERT INTO task_sessions (
|
||||
id, task_id, agent_profile_id, executor_id, executor_profile_id, environment_id,
|
||||
repository_id, base_branch, base_commit_sha,
|
||||
repository_id, base_branch, base_commit_sha, workspace_path,
|
||||
agent_profile_snapshot, executor_snapshot, environment_snapshot, repository_snapshot,
|
||||
state, error_message, metadata, started_at, completed_at, updated_at,
|
||||
is_primary, review_status, is_passthrough, task_environment_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), session.ID, session.TaskID, session.AgentProfileID,
|
||||
session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA,
|
||||
session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID, session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath,
|
||||
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
|
||||
string(session.State), session.ErrorMessage, string(metadataJSON),
|
||||
session.StartedAt, session.CompletedAt, session.UpdatedAt,
|
||||
@@ -255,7 +255,7 @@ func (r *Repository) scanTaskSession(ctx context.Context, row *sql.Row, noRowsEr
|
||||
err := row.Scan(
|
||||
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
|
||||
&session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID,
|
||||
&session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA,
|
||||
&session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &session.WorkspacePath,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID,
|
||||
@@ -308,7 +308,7 @@ func (r *Repository) GetTaskSession(ctx context.Context, id string) (*models.Tas
|
||||
SELECT ts.id, ts.task_id,
|
||||
COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''),
|
||||
ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path,
|
||||
ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot,
|
||||
ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at,
|
||||
ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id
|
||||
@@ -324,7 +324,7 @@ func (r *Repository) GetTaskSessionByTaskID(ctx context.Context, taskID string)
|
||||
SELECT ts.id, ts.task_id,
|
||||
COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''),
|
||||
ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path,
|
||||
ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot,
|
||||
ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at,
|
||||
ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id
|
||||
@@ -340,7 +340,7 @@ func (r *Repository) GetActiveTaskSessionByTaskID(ctx context.Context, taskID st
|
||||
SELECT ts.id, ts.task_id,
|
||||
COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''),
|
||||
ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path,
|
||||
ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot,
|
||||
ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at,
|
||||
ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id
|
||||
@@ -379,13 +379,13 @@ func (r *Repository) UpdateTaskSession(ctx context.Context, session *models.Task
|
||||
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
||||
UPDATE task_sessions SET
|
||||
agent_profile_id = ?, executor_id = ?, executor_profile_id = ?, environment_id = ?,
|
||||
repository_id = ?, base_branch = ?, base_commit_sha = ?,
|
||||
repository_id = ?, base_branch = ?, base_commit_sha = ?, workspace_path = ?,
|
||||
agent_profile_snapshot = ?, executor_snapshot = ?, environment_snapshot = ?, repository_snapshot = ?,
|
||||
state = ?, error_message = ?, completed_at = ?, updated_at = ?,
|
||||
is_primary = ?, review_status = ?, is_passthrough = ?, task_environment_id = ?
|
||||
WHERE id = ?
|
||||
`), session.AgentProfileID, session.ExecutorID, session.ExecutorProfileID, session.EnvironmentID,
|
||||
session.RepositoryID, session.BaseBranch, session.BaseCommitSHA,
|
||||
session.RepositoryID, session.BaseBranch, session.BaseCommitSHA, session.WorkspacePath,
|
||||
string(agentProfileSnapshotJSON), string(executorSnapshotJSON), string(environmentSnapshotJSON), string(repositorySnapshotJSON),
|
||||
string(session.State), session.ErrorMessage, session.CompletedAt, session.UpdatedAt,
|
||||
dialect.BoolToInt(session.IsPrimary), session.ReviewStatus,
|
||||
@@ -494,7 +494,7 @@ func (r *Repository) ListTaskSessions(ctx context.Context, taskID string) ([]*mo
|
||||
SELECT ts.id, ts.task_id,
|
||||
COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''),
|
||||
ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path,
|
||||
ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot,
|
||||
ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at,
|
||||
ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id
|
||||
@@ -521,7 +521,7 @@ func (r *Repository) ListActiveTaskSessions(ctx context.Context) ([]*models.Task
|
||||
SELECT ts.id, ts.task_id,
|
||||
COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''),
|
||||
ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path,
|
||||
ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot,
|
||||
ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at,
|
||||
ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id
|
||||
@@ -546,7 +546,7 @@ func (r *Repository) ListActiveTaskSessionsByTaskID(ctx context.Context, taskID
|
||||
SELECT ts.id, ts.task_id,
|
||||
COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''),
|
||||
ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path,
|
||||
ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot,
|
||||
ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at,
|
||||
ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id
|
||||
@@ -701,7 +701,7 @@ func scanTaskSessionRow(rows *sql.Rows) (*models.TaskSession, error) {
|
||||
err := rows.Scan(
|
||||
&session.ID, &session.TaskID, &session.AgentExecutionID, &session.ContainerID, &session.AgentProfileID,
|
||||
&session.ExecutorID, &session.ExecutorProfileID, &session.EnvironmentID,
|
||||
&session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA,
|
||||
&session.RepositoryID, &session.BaseBranch, &session.BaseCommitSHA, &session.WorkspacePath,
|
||||
&agentProfileSnapshotJSON, &executorSnapshotJSON, &environmentSnapshotJSON, &repositorySnapshotJSON,
|
||||
&state, &session.ErrorMessage, &metadataJSON, &session.StartedAt, &completedAt, &session.UpdatedAt,
|
||||
&isPrimary, &reviewStatus, &isPassthrough, &session.TaskEnvironmentID,
|
||||
@@ -910,7 +910,7 @@ func (r *Repository) GetPrimarySessionByTaskID(ctx context.Context, taskID strin
|
||||
SELECT ts.id, ts.task_id,
|
||||
COALESCE(er.agent_execution_id, ''), COALESCE(er.container_id, ''),
|
||||
ts.agent_profile_id, ts.executor_id, ts.executor_profile_id, ts.environment_id,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha,
|
||||
ts.repository_id, ts.base_branch, ts.base_commit_sha, ts.workspace_path,
|
||||
ts.agent_profile_snapshot, ts.executor_snapshot, ts.environment_snapshot, ts.repository_snapshot,
|
||||
ts.state, ts.error_message, ts.metadata, ts.started_at, ts.completed_at, ts.updated_at,
|
||||
ts.is_primary, ts.review_status, ts.is_passthrough, ts.task_environment_id
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DirectoryEntry is one immediate child of a listed directory.
|
||||
type DirectoryEntry struct {
|
||||
Name string
|
||||
Path string // absolute path
|
||||
}
|
||||
|
||||
// DirectoryListing is the result of ListDirectory: the absolute path that was
|
||||
// listed, the parent (empty when at the filesystem root), and the immediate
|
||||
// subdirectory children sorted alphabetically.
|
||||
type DirectoryListing struct {
|
||||
Path string
|
||||
Parent string
|
||||
Entries []DirectoryEntry
|
||||
}
|
||||
|
||||
// ListDirectory returns the immediate subdirectories of path. When path is
|
||||
// empty it falls back to $HOME. The picker is deliberately *not* anchored
|
||||
// to discoveryRoots — users browsing for a starting folder for a repo-less
|
||||
// task may legitimately want /tmp, /var/log/foo, or any other directory
|
||||
// they have read access to. The repo-discover endpoint stays locked down
|
||||
// to discoveryRoots; this one trusts the local-process boundary that runs
|
||||
// kandev on the user's own machine.
|
||||
//
|
||||
// Filesystem operations go through os.Root opened at "/", which the Go
|
||||
// stdlib enforces at the syscall level (no symlink escape, traversal in
|
||||
// the relative path is rooted to "/"). That's a no-op for security because
|
||||
// "/" is the filesystem root — but it's what CodeQL recognises as a
|
||||
// path-injection sanitizer, so the lint stays green.
|
||||
//
|
||||
// Hidden (".") directories are excluded.
|
||||
func (s *Service) ListDirectory(ctx context.Context, path string) (DirectoryListing, error) {
|
||||
abs, err := resolveListingPath(path)
|
||||
if err != nil {
|
||||
return DirectoryListing{}, err
|
||||
}
|
||||
|
||||
entries, err := readSubdirsBoundedAtFilesystemRoot(abs)
|
||||
if err != nil {
|
||||
return DirectoryListing{}, err
|
||||
}
|
||||
|
||||
_ = ctx
|
||||
return DirectoryListing{
|
||||
Path: abs,
|
||||
Parent: parentPath(abs),
|
||||
Entries: collectSubdirs(abs, entries),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// resolveListingPath cleans the user-supplied path. Empty path defaults to
|
||||
// $HOME. The returned path is always absolute and cleaned.
|
||||
func resolveListingPath(path string) (string, error) {
|
||||
if path == "" {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve home: %w", err)
|
||||
}
|
||||
return filepath.Clean(home), nil
|
||||
}
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
return filepath.Clean(abs), nil
|
||||
}
|
||||
|
||||
// readSubdirsBoundedAtFilesystemRoot opens "/" via os.Root and reads the
|
||||
// directory at the relative path inside it. Using os.Root here is for
|
||||
// CodeQL's benefit: the Go stdlib refuses symlink escape and rejects
|
||||
// traversal segments in the relative path, and CodeQL models os.Root.Open
|
||||
// as a sanitizer for the path-injection query. Because the root is "/",
|
||||
// containment is trivially satisfied — every absolute path on the host is
|
||||
// inside it.
|
||||
func readSubdirsBoundedAtFilesystemRoot(abs string) ([]os.DirEntry, error) {
|
||||
root, err := os.OpenRoot("/")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open root: %w", err)
|
||||
}
|
||||
defer func() { _ = root.Close() }()
|
||||
|
||||
rel := strings.TrimPrefix(abs, "/")
|
||||
if rel == "" {
|
||||
rel = "."
|
||||
}
|
||||
|
||||
info, err := root.Stat(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return nil, fmt.Errorf("not a directory: %s", abs)
|
||||
}
|
||||
|
||||
dir, err := root.Open(rel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = dir.Close() }()
|
||||
return dir.ReadDir(-1)
|
||||
}
|
||||
|
||||
// parentPath returns the parent of abs, or "" when abs is the filesystem
|
||||
// root. The picker's "up" button stops at "/" so the user can't navigate
|
||||
// past the top of the host filesystem.
|
||||
func parentPath(abs string) string {
|
||||
parent := filepath.Dir(abs)
|
||||
if parent == abs {
|
||||
return ""
|
||||
}
|
||||
return parent
|
||||
}
|
||||
|
||||
// collectSubdirs filters entries to immediate subdirectories, drops hidden
|
||||
// (dotfile) directories, and returns them sorted alphabetically (case-fold).
|
||||
func collectSubdirs(parent string, entries []os.DirEntry) []DirectoryEntry {
|
||||
out := make([]DirectoryEntry, 0, len(entries))
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
out = append(out, DirectoryEntry{
|
||||
Name: name,
|
||||
Path: filepath.Join(parent, name),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
return strings.ToLower(out[i].Name) < strings.ToLower(out[j].Name)
|
||||
})
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestListDirectory_ListsImmediateSubdirsOnly(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for _, name := range []string{"alpha", "beta", "gamma"} {
|
||||
if err := os.Mkdir(filepath.Join(root, name), 0o755); err != nil {
|
||||
t.Fatalf("mkdir: %v", err)
|
||||
}
|
||||
}
|
||||
// File should not appear in listing.
|
||||
if err := os.WriteFile(filepath.Join(root, "ignore-me.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
// Hidden directory should be excluded.
|
||||
if err := os.Mkdir(filepath.Join(root, ".hidden"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir hidden: %v", err)
|
||||
}
|
||||
// Nested dir should NOT appear (immediate children only).
|
||||
if err := os.MkdirAll(filepath.Join(root, "alpha", "deep"), 0o755); err != nil {
|
||||
t.Fatalf("mkdir nested: %v", err)
|
||||
}
|
||||
|
||||
svc := &Service{}
|
||||
got, err := svc.ListDirectory(context.Background(), root)
|
||||
if err != nil {
|
||||
t.Fatalf("ListDirectory: %v", err)
|
||||
}
|
||||
|
||||
want := []string{"alpha", "beta", "gamma"}
|
||||
if len(got.Entries) != len(want) {
|
||||
t.Fatalf("entries: got %d, want %d (%+v)", len(got.Entries), len(want), got.Entries)
|
||||
}
|
||||
for i, e := range got.Entries {
|
||||
if e.Name != want[i] {
|
||||
t.Errorf("entry[%d].Name = %q; want %q", i, e.Name, want[i])
|
||||
}
|
||||
}
|
||||
// A t.TempDir is not the filesystem root, so the parent should be set.
|
||||
if got.Parent == "" {
|
||||
t.Errorf("expected parent to be set for nested path, got empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirectory_DefaultsToHome(t *testing.T) {
|
||||
svc := &Service{}
|
||||
got, err := svc.ListDirectory(context.Background(), "")
|
||||
if err != nil {
|
||||
t.Fatalf("ListDirectory: %v", err)
|
||||
}
|
||||
home, _ := os.UserHomeDir()
|
||||
if got.Path != filepath.Clean(home) {
|
||||
t.Errorf("got Path = %q; want home %q", got.Path, home)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirectory_RejectsNonDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
file := filepath.Join(root, "not-a-dir")
|
||||
if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write file: %v", err)
|
||||
}
|
||||
svc := &Service{}
|
||||
_, err := svc.ListDirectory(context.Background(), file)
|
||||
if err == nil {
|
||||
t.Fatalf("expected error for non-directory path, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirectory_BrowsesOutsideHome(t *testing.T) {
|
||||
// The picker deliberately lets users browse any directory the kandev
|
||||
// process has read access to, not just $HOME or the discoveryRoots.
|
||||
// /tmp is the canonical "outside $HOME but accessible" path on most
|
||||
// Unix CI runners; assert we can list it without error.
|
||||
svc := &Service{}
|
||||
got, err := svc.ListDirectory(context.Background(), "/tmp")
|
||||
if err != nil {
|
||||
t.Fatalf("ListDirectory(/tmp): %v", err)
|
||||
}
|
||||
if got.Path != "/tmp" {
|
||||
t.Errorf("got Path = %q; want /tmp", got.Path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDirectory_ParentEmptyAtFilesystemRoot(t *testing.T) {
|
||||
svc := &Service{}
|
||||
got, err := svc.ListDirectory(context.Background(), "/")
|
||||
if err != nil {
|
||||
t.Fatalf("ListDirectory(/): %v", err)
|
||||
}
|
||||
if got.Parent != "" {
|
||||
t.Errorf("expected empty parent at /, got %q", got.Parent)
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ type CreateTaskRequest struct {
|
||||
PlanMode bool `json:"plan_mode,omitempty"`
|
||||
IsEphemeral bool `json:"is_ephemeral,omitempty"` // Ephemeral tasks are hidden from kanban, used for quick chat
|
||||
ParentID string `json:"parent_id,omitempty"`
|
||||
WorkspacePath string `json:"workspace_path,omitempty"` // Optional host folder for repo-less tasks
|
||||
}
|
||||
|
||||
// UpdateTaskRequest contains the data for updating a task
|
||||
|
||||
@@ -62,6 +62,14 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode
|
||||
if req.State != nil {
|
||||
state = *req.State
|
||||
}
|
||||
metadata := req.Metadata
|
||||
workspacePath := strings.TrimSpace(req.WorkspacePath)
|
||||
if workspacePath != "" {
|
||||
if metadata == nil {
|
||||
metadata = make(map[string]interface{})
|
||||
}
|
||||
metadata[models.MetaKeyWorkspacePath] = workspacePath
|
||||
}
|
||||
task := &models.Task{
|
||||
ID: uuid.New().String(),
|
||||
WorkspaceID: req.WorkspaceID,
|
||||
@@ -72,7 +80,7 @@ func (s *Service) CreateTask(ctx context.Context, req *CreateTaskRequest) (*mode
|
||||
State: state,
|
||||
Priority: req.Priority,
|
||||
Position: req.Position,
|
||||
Metadata: req.Metadata,
|
||||
Metadata: metadata,
|
||||
IsEphemeral: req.IsEphemeral,
|
||||
ParentID: req.ParentID,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment, useCallback, useEffect, useState } from "react";
|
||||
import { IconFolder, IconBox, IconChevronRight } from "@tabler/icons-react";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { listDirectory, type DirectoryListing } from "@/lib/api/domains/fs-api";
|
||||
|
||||
type FolderPickerProps = {
|
||||
/** Currently chosen absolute path; empty when nothing is picked. */
|
||||
value: string;
|
||||
/** Called with the absolute path when the user chooses a folder. */
|
||||
onChange: (path: string) => void;
|
||||
placeholder?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Folder picker for repo-less tasks. Drives GET /api/v1/fs/list-dir on the
|
||||
* local kandev backend (browsers can't enumerate the host filesystem). The
|
||||
* trigger lives in the chip row and the popover handles browse + commit.
|
||||
*/
|
||||
export function FolderPicker({ value, onChange, placeholder }: FolderPickerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { listing, loading, error, load } = useDirectoryListing(open, value);
|
||||
const leaf = leafName(value);
|
||||
const triggerLabel = leaf || placeholder || "scratch workspace";
|
||||
const hasValue = !!value;
|
||||
|
||||
const triggerClass = cn(
|
||||
"h-7 inline-flex items-center gap-1.5 rounded-md px-2.5 text-xs cursor-pointer",
|
||||
"border border-border/60 transition-colors",
|
||||
hasValue
|
||||
? "bg-primary/10 text-foreground hover:bg-primary/15"
|
||||
: "bg-muted/30 text-muted-foreground hover:bg-muted/60",
|
||||
);
|
||||
|
||||
const trigger = (
|
||||
<button type="button" data-testid="folder-picker-trigger" className={triggerClass}>
|
||||
{hasValue ? (
|
||||
<IconFolder className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
) : (
|
||||
<IconBox className="h-3.5 w-3.5 flex-shrink-0" />
|
||||
)}
|
||||
<span className="truncate max-w-[260px]">{triggerLabel}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
{hasValue ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent className="font-mono text-[11px]">{value}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
trigger
|
||||
)}
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[440px] gap-0 p-0 overflow-hidden"
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
data-testid="folder-picker-popover"
|
||||
>
|
||||
<Breadcrumb path={listing?.path ?? ""} onNavigate={(p) => void load(p)} />
|
||||
<Entries
|
||||
listing={listing}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onDescend={(p) => void load(p)}
|
||||
/>
|
||||
<Footer
|
||||
choosable={!!listing?.path}
|
||||
onUseScratch={() => {
|
||||
onChange("");
|
||||
setOpen(false);
|
||||
}}
|
||||
onChoose={() => {
|
||||
if (!listing) return;
|
||||
onChange(listing.path);
|
||||
setOpen(false);
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
function leafName(path: string): string {
|
||||
if (!path) return "";
|
||||
const trimmed = path.replace(/\/+$/, "");
|
||||
const idx = trimmed.lastIndexOf("/");
|
||||
return idx === -1 ? trimmed : trimmed.slice(idx + 1) || "/";
|
||||
}
|
||||
|
||||
function useDirectoryListing(open: boolean, value: string) {
|
||||
const [listing, setListing] = useState<DirectoryListing | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async (path: string) => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setListing(await listDirectory(path));
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to load directory");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Reset cached listing when the popover closes so the next open reloads
|
||||
// from `value`. Without this, a user who browsed deep into the tree,
|
||||
// closed without committing, and reopened would still see the stale
|
||||
// last-browsed directory instead of their picked folder (or the root).
|
||||
if (!open) {
|
||||
setListing(null);
|
||||
return;
|
||||
}
|
||||
if (listing) return;
|
||||
void load(value || "");
|
||||
}, [open, listing, load, value]);
|
||||
|
||||
return { listing, loading, error, load };
|
||||
}
|
||||
|
||||
/** Splits an absolute path into clickable breadcrumb segments. */
|
||||
function pathSegments(path: string): Array<{ name: string; path: string }> {
|
||||
if (!path) return [];
|
||||
const parts = path.split("/").filter(Boolean);
|
||||
const segs: Array<{ name: string; path: string }> = [{ name: "/", path: "/" }];
|
||||
let acc = "";
|
||||
for (const part of parts) {
|
||||
acc += `/${part}`;
|
||||
segs.push({ name: part, path: acc });
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
function Breadcrumb({ path, onNavigate }: { path: string; onNavigate: (p: string) => void }) {
|
||||
const segs = pathSegments(path);
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 border-b border-border bg-muted/30 px-2 py-1.5 overflow-x-auto">
|
||||
{segs.length === 0 && (
|
||||
<span className="text-[11px] text-muted-foreground italic">Loading…</span>
|
||||
)}
|
||||
{segs.map((seg, i) => {
|
||||
const last = i === segs.length - 1;
|
||||
return (
|
||||
<Fragment key={seg.path}>
|
||||
{i > 0 && (
|
||||
<IconChevronRight className="h-3 w-3 flex-shrink-0 text-muted-foreground/60" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => !last && onNavigate(seg.path)}
|
||||
disabled={last}
|
||||
className={cn(
|
||||
"rounded px-1.5 py-0.5 text-[11px] font-mono whitespace-nowrap",
|
||||
last
|
||||
? "text-foreground cursor-default"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer",
|
||||
)}
|
||||
>
|
||||
{seg.name}
|
||||
</button>
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Entries({
|
||||
listing,
|
||||
loading,
|
||||
error,
|
||||
onDescend,
|
||||
}: {
|
||||
listing: DirectoryListing | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
onDescend: (path: string) => void;
|
||||
}) {
|
||||
if (loading) return <EmptyRow text="Loading…" />;
|
||||
if (error) return <EmptyRow text={error} variant="error" testId="folder-picker-error" />;
|
||||
if (!listing || listing.entries.length === 0) {
|
||||
return <EmptyRow text="No folders here — pick this one or go up" />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="max-h-[280px] overflow-y-auto overscroll-contain py-1"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
{listing.entries.map((entry) => (
|
||||
<button
|
||||
key={entry.path}
|
||||
type="button"
|
||||
onClick={() => onDescend(entry.path)}
|
||||
className="group flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs hover:bg-accent cursor-pointer"
|
||||
data-testid="folder-picker-entry"
|
||||
>
|
||||
<IconFolder className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground group-hover:text-foreground" />
|
||||
<span className="truncate flex-1">{entry.name}</span>
|
||||
<IconChevronRight className="h-3 w-3 flex-shrink-0 text-muted-foreground/40 group-hover:text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyRow({ text, variant, testId }: { text: string; variant?: "error"; testId?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"py-8 text-center text-xs",
|
||||
variant === "error" ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
data-testid={testId}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Footer({
|
||||
choosable,
|
||||
onUseScratch,
|
||||
onChoose,
|
||||
}: {
|
||||
choosable: boolean;
|
||||
onUseScratch: () => void;
|
||||
onChoose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2 border-t border-border bg-muted/20 px-2 py-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onUseScratch}
|
||||
data-testid="folder-picker-use-scratch"
|
||||
className="inline-flex items-center gap-1.5 rounded px-2 py-1 text-[11px] text-muted-foreground hover:bg-accent hover:text-foreground cursor-pointer"
|
||||
>
|
||||
<IconBox className="h-3 w-3" />
|
||||
Use scratch instead
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!choosable}
|
||||
onClick={onChoose}
|
||||
data-testid="folder-picker-choose"
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded px-2.5 py-1 text-[11px] font-medium transition-colors cursor-pointer",
|
||||
"bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
"disabled:opacity-40 disabled:cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
<IconFolder className="h-3 w-3" />
|
||||
Use this folder
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -213,6 +213,26 @@ export function useCurrentLocalBranchEffect(
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Picks the default executor ID to auto-fill on dialog open. Repo-less tasks
|
||||
* skip the worktree executor (it needs a repo); other modes use the workspace
|
||||
* default → DEFAULT_LOCAL_EXECUTOR_TYPE → first available, in priority order.
|
||||
*/
|
||||
function pickDefaultExecutorId(
|
||||
executors: Executor[],
|
||||
workspaceDefaults: { default_executor_id?: string | null } | null | undefined,
|
||||
noRepository: boolean,
|
||||
): string | null {
|
||||
const eligible = noRepository
|
||||
? executors.filter((e: Executor) => e.type !== "worktree")
|
||||
: executors;
|
||||
if (eligible.length === 0) return null;
|
||||
const defId = workspaceDefaults?.default_executor_id ?? null;
|
||||
if (defId && eligible.some((e: Executor) => e.id === defId)) return defId;
|
||||
const local = eligible.find((e: Executor) => e.type === DEFAULT_LOCAL_EXECUTOR_TYPE);
|
||||
return local?.id ?? eligible[0].id;
|
||||
}
|
||||
|
||||
export function useDefaultSelectionsEffect(
|
||||
fs: DialogFormState,
|
||||
open: boolean,
|
||||
@@ -229,6 +249,7 @@ export function useDefaultSelectionsEffect(
|
||||
setAgentProfileId,
|
||||
setExecutorId,
|
||||
setExecutorProfileId,
|
||||
noRepository,
|
||||
} = fs;
|
||||
useEffect(() => {
|
||||
// Check synchronously whether the selected workflow has an agent override.
|
||||
@@ -269,14 +290,9 @@ export function useDefaultSelectionsEffect(
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || executorId || executors.length === 0) return;
|
||||
const defId = workspaceDefaults?.default_executor_id ?? null;
|
||||
if (defId && executors.some((e: Executor) => e.id === defId)) {
|
||||
void Promise.resolve().then(() => setExecutorId(defId));
|
||||
return;
|
||||
}
|
||||
const local = executors.find((e: Executor) => e.type === DEFAULT_LOCAL_EXECUTOR_TYPE);
|
||||
void Promise.resolve().then(() => setExecutorId(local?.id ?? executors[0].id));
|
||||
}, [open, executorId, executors, workspaceDefaults, setExecutorId]);
|
||||
const pick = pickDefaultExecutorId(executors, workspaceDefaults, noRepository);
|
||||
if (pick) void Promise.resolve().then(() => setExecutorId(pick));
|
||||
}, [open, executorId, executors, workspaceDefaults, setExecutorId, noRepository]);
|
||||
|
||||
useEffect(() => {
|
||||
// Auto-select executor profile: last used (localStorage) → first available
|
||||
|
||||
@@ -143,10 +143,40 @@ function useGitHubAndFreshBranchHandlers(fs: DialogFormState) {
|
||||
[fs],
|
||||
);
|
||||
|
||||
/**
|
||||
* Toggles "no repository" mode. Replaces the chip row with a folder picker.
|
||||
* Clears the URL-mode state and the workspace_path so flipping back returns
|
||||
* the user to a clean slate.
|
||||
*/
|
||||
const handleToggleNoRepository = useCallback(() => {
|
||||
const next = !fs.noRepository;
|
||||
fs.setNoRepository(next);
|
||||
if (next) {
|
||||
fs.setUseGitHubUrl(false);
|
||||
fs.setGitHubUrl("");
|
||||
fs.setGitHubBranch("");
|
||||
// Clear the executor selection so the auto-fill effect re-picks a
|
||||
// non-worktree default (worktree is unworkable in no-repo mode).
|
||||
fs.setExecutorId("");
|
||||
fs.setExecutorProfileId("");
|
||||
} else {
|
||||
fs.setWorkspacePath("");
|
||||
}
|
||||
}, [fs]);
|
||||
|
||||
const handleWorkspacePathChange = useCallback(
|
||||
(value: string) => {
|
||||
fs.setWorkspacePath(value);
|
||||
},
|
||||
[fs],
|
||||
);
|
||||
|
||||
return {
|
||||
handleToggleGitHubUrl,
|
||||
handleToggleFreshBranch,
|
||||
handleGitHubUrlChange,
|
||||
handleToggleNoRepository,
|
||||
handleWorkspacePathChange,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -113,6 +113,7 @@ export type BuildCreatePayloadArgs = {
|
||||
planMode?: boolean;
|
||||
attachments?: MessageAttachment[];
|
||||
parentId?: string;
|
||||
workspacePath?: string;
|
||||
};
|
||||
|
||||
export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTaskParams {
|
||||
@@ -131,6 +132,7 @@ export function buildCreateTaskPayload(args: BuildCreatePayloadArgs): CreateTask
|
||||
plan_mode: args.planMode || undefined,
|
||||
attachments: args.attachments,
|
||||
parent_id: args.parentId || undefined,
|
||||
workspace_path: args.workspacePath || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,8 +144,10 @@ export function validateCreateInputs(inputs: {
|
||||
repositories: TaskRepoRow[];
|
||||
githubUrl?: string;
|
||||
agentProfileId: string;
|
||||
noRepository?: boolean;
|
||||
}): boolean {
|
||||
const hasRepo =
|
||||
inputs.noRepository ||
|
||||
inputs.repositories.some((r) => r.repositoryId || r.localPath) ||
|
||||
Boolean(inputs.githubUrl?.trim());
|
||||
return Boolean(
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type PillOption,
|
||||
} from "@/components/task-create-dialog-pill";
|
||||
import { GitHubUrlSection } from "@/components/task-create-dialog-github-url";
|
||||
import { FolderPicker } from "@/components/folder-picker";
|
||||
import { SourceModeSwitch } from "@/components/task-create-dialog-source-mode";
|
||||
import {
|
||||
computeBranchPrefix,
|
||||
computeBranchTooltip,
|
||||
@@ -97,6 +99,9 @@ type RepoChipsRowProps = {
|
||||
* mode is independent: it creates a new branch from a chosen base.
|
||||
*/
|
||||
isLocalExecutor?: boolean;
|
||||
/** "No repository" mode: replace the chip row with a folder picker. */
|
||||
onToggleNoRepository?: () => void;
|
||||
onWorkspacePathChange?: (value: string) => void;
|
||||
};
|
||||
|
||||
export function RepoChipsRow({
|
||||
@@ -112,6 +117,8 @@ export function RepoChipsRow({
|
||||
freshBranchEnabled,
|
||||
onToggleFreshBranch,
|
||||
isLocalExecutor,
|
||||
onToggleNoRepository,
|
||||
onWorkspacePathChange,
|
||||
}: RepoChipsRowProps) {
|
||||
// Local executor branch behavior:
|
||||
// - chip is clickable (user can switch to any existing branch on disk)
|
||||
@@ -137,51 +144,112 @@ export function RepoChipsRow({
|
||||
const addHint = computeAddHint(canAddMore, repositories.length);
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2" data-testid="repo-chips-row">
|
||||
{fs.useGitHubUrl ? (
|
||||
<GitHubUrlSection
|
||||
githubUrl={fs.githubUrl}
|
||||
githubUrlError={fs.githubUrlError}
|
||||
githubBranch={fs.githubBranch}
|
||||
githubBranches={fs.githubBranches}
|
||||
githubBranchesLoading={fs.githubBranchesLoading}
|
||||
onGitHubUrlChange={onGitHubUrlChange}
|
||||
onGitHubBranchChange={fs.setGitHubBranch}
|
||||
/>
|
||||
) : (
|
||||
<ChipsList
|
||||
fs={fs}
|
||||
repositories={repositories}
|
||||
workspaceId={workspaceId}
|
||||
branchLocked={branchLocked}
|
||||
isLocalExecutor={!!isLocalExecutor}
|
||||
canAddMore={canAddMore}
|
||||
addHint={addHint}
|
||||
onRowRepositoryChange={onRowRepositoryChange}
|
||||
onRowBranchChange={onRowBranchChange}
|
||||
freshBranchToggle={
|
||||
// Multi-repo runs use worktrees, so the existing-vs-fork choice
|
||||
// is irrelevant — only surface the toggle for single-repo flows.
|
||||
freshBranchAvailable && onToggleFreshBranch && fs.repositories.length === 1 ? (
|
||||
<FreshBranchToggle enabled={!!freshBranchEnabled} onToggle={onToggleFreshBranch} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{onToggleGitHubUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleGitHubUrl}
|
||||
className="ml-auto text-[11px] text-muted-foreground hover:text-foreground cursor-pointer"
|
||||
data-testid="toggle-github-url"
|
||||
>
|
||||
{fs.useGitHubUrl ? "use a workspace repository" : "or paste a GitHub URL"}
|
||||
</button>
|
||||
)}
|
||||
// min-h-9 reserves enough vertical space for the tallest mode body so the
|
||||
// modal doesn't jump when the user toggles between Repo / URL / None
|
||||
// (None renders a single pill, Repo can render chips + branch + add and
|
||||
// sometimes wraps when the segmented control crowds the row).
|
||||
<div className="flex min-h-9 flex-wrap items-center gap-2" data-testid="repo-chips-row">
|
||||
<ModeBody
|
||||
fs={fs}
|
||||
repositories={repositories}
|
||||
workspaceId={workspaceId}
|
||||
branchLocked={branchLocked}
|
||||
isLocalExecutor={!!isLocalExecutor}
|
||||
canAddMore={canAddMore}
|
||||
addHint={addHint}
|
||||
freshBranchAvailable={freshBranchAvailable}
|
||||
freshBranchEnabled={freshBranchEnabled}
|
||||
onRowRepositoryChange={onRowRepositoryChange}
|
||||
onRowBranchChange={onRowBranchChange}
|
||||
onGitHubUrlChange={onGitHubUrlChange}
|
||||
onToggleFreshBranch={onToggleFreshBranch}
|
||||
onWorkspacePathChange={onWorkspacePathChange}
|
||||
/>
|
||||
<SourceModeSwitch
|
||||
useGitHubUrl={fs.useGitHubUrl}
|
||||
noRepository={fs.noRepository}
|
||||
onToggleGitHubUrl={onToggleGitHubUrl}
|
||||
onToggleNoRepository={onToggleNoRepository}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ModeBody({
|
||||
fs,
|
||||
repositories,
|
||||
workspaceId,
|
||||
branchLocked,
|
||||
isLocalExecutor,
|
||||
canAddMore,
|
||||
addHint,
|
||||
freshBranchAvailable,
|
||||
freshBranchEnabled,
|
||||
onRowRepositoryChange,
|
||||
onRowBranchChange,
|
||||
onGitHubUrlChange,
|
||||
onToggleFreshBranch,
|
||||
onWorkspacePathChange,
|
||||
}: {
|
||||
fs: DialogFormState;
|
||||
repositories: Repository[];
|
||||
workspaceId: string | null;
|
||||
branchLocked: boolean;
|
||||
isLocalExecutor: boolean;
|
||||
canAddMore: boolean;
|
||||
addHint: string | undefined;
|
||||
freshBranchAvailable?: boolean;
|
||||
freshBranchEnabled?: boolean;
|
||||
onRowRepositoryChange: (key: string, value: string) => void;
|
||||
onRowBranchChange: (key: string, value: string) => void;
|
||||
onGitHubUrlChange?: (value: string) => void;
|
||||
onToggleFreshBranch?: (enabled: boolean) => void;
|
||||
onWorkspacePathChange?: (value: string) => void;
|
||||
}) {
|
||||
if (fs.noRepository) {
|
||||
return (
|
||||
<FolderPicker
|
||||
value={fs.workspacePath}
|
||||
onChange={onWorkspacePathChange ?? (() => {})}
|
||||
placeholder="pick a starting folder (optional)"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (fs.useGitHubUrl) {
|
||||
return (
|
||||
<GitHubUrlSection
|
||||
githubUrl={fs.githubUrl}
|
||||
githubUrlError={fs.githubUrlError}
|
||||
githubBranch={fs.githubBranch}
|
||||
githubBranches={fs.githubBranches}
|
||||
githubBranchesLoading={fs.githubBranchesLoading}
|
||||
onGitHubUrlChange={onGitHubUrlChange}
|
||||
onGitHubBranchChange={fs.setGitHubBranch}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ChipsList
|
||||
fs={fs}
|
||||
repositories={repositories}
|
||||
workspaceId={workspaceId}
|
||||
branchLocked={branchLocked}
|
||||
isLocalExecutor={isLocalExecutor}
|
||||
canAddMore={canAddMore}
|
||||
addHint={addHint}
|
||||
onRowRepositoryChange={onRowRepositoryChange}
|
||||
onRowBranchChange={onRowBranchChange}
|
||||
freshBranchToggle={
|
||||
// Multi-repo runs use worktrees, so the existing-vs-fork choice
|
||||
// is irrelevant — only surface the toggle for single-repo flows.
|
||||
freshBranchAvailable && onToggleFreshBranch && fs.repositories.length === 1 ? (
|
||||
<FreshBranchToggle enabled={!!freshBranchEnabled} onToggle={onToggleFreshBranch} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the list of repo chips plus the trailing "+ add repository"
|
||||
* button. Extracted from RepoChipsRow so the parent stays under the
|
||||
|
||||
@@ -203,6 +203,8 @@ function useSubmitHandlersWiring({
|
||||
freshBranchEnabled: fs.freshBranchEnabled,
|
||||
isLocalExecutor: computed.isLocalExecutor,
|
||||
repositoryLocalPath,
|
||||
noRepository: fs.noRepository,
|
||||
workspacePath: fs.workspacePath,
|
||||
transformDescriptionBeforeSubmit: props.transformDescriptionBeforeSubmit,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type SourceMode = "workspace" | "url" | "scratch";
|
||||
|
||||
function resolveMode(useGitHubUrl: boolean, noRepository: boolean): SourceMode {
|
||||
if (noRepository) return "scratch";
|
||||
if (useGitHubUrl) return "url";
|
||||
return "workspace";
|
||||
}
|
||||
|
||||
type SourceModeSwitchProps = {
|
||||
useGitHubUrl: boolean;
|
||||
noRepository: boolean;
|
||||
onToggleGitHubUrl?: () => void;
|
||||
onToggleNoRepository?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Three-mode segmented control rendered at the right edge of the chip row:
|
||||
* - Repo : pick a workspace repository (default)
|
||||
* - URL : paste a GitHub URL
|
||||
* - None : run in a scratch workspace or a folder you pick
|
||||
*
|
||||
* Switching modes calls the underlying single-mode togglers in the right
|
||||
* order so we never end up in two modes at once. Hidden when no togglers
|
||||
* are provided (e.g. locked-fields wrapper).
|
||||
*/
|
||||
export function SourceModeSwitch({
|
||||
useGitHubUrl,
|
||||
noRepository,
|
||||
onToggleGitHubUrl,
|
||||
onToggleNoRepository,
|
||||
}: SourceModeSwitchProps) {
|
||||
if (!onToggleGitHubUrl && !onToggleNoRepository) return null;
|
||||
const mode = resolveMode(useGitHubUrl, noRepository);
|
||||
const setMode = (next: SourceMode) =>
|
||||
switchMode({
|
||||
from: mode,
|
||||
to: next,
|
||||
onToggleGitHubUrl,
|
||||
onToggleNoRepository,
|
||||
});
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Source"
|
||||
className="ml-auto inline-flex items-center rounded-md border border-border/60 bg-muted/20 p-0.5"
|
||||
>
|
||||
<ModeButton label="Repo" mode="workspace" active={mode} onSelect={setMode} />
|
||||
{onToggleGitHubUrl && <ModeButton label="URL" mode="url" active={mode} onSelect={setMode} />}
|
||||
{onToggleNoRepository && (
|
||||
<ModeButton label="None" mode="scratch" active={mode} onSelect={setMode} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function switchMode({
|
||||
from,
|
||||
to,
|
||||
onToggleGitHubUrl,
|
||||
onToggleNoRepository,
|
||||
}: {
|
||||
from: SourceMode;
|
||||
to: SourceMode;
|
||||
onToggleGitHubUrl?: () => void;
|
||||
onToggleNoRepository?: () => void;
|
||||
}) {
|
||||
if (from === to) return;
|
||||
if (to === "url") {
|
||||
if (from === "scratch") onToggleNoRepository?.();
|
||||
onToggleGitHubUrl?.();
|
||||
return;
|
||||
}
|
||||
if (to === "scratch") {
|
||||
if (from === "url") onToggleGitHubUrl?.();
|
||||
onToggleNoRepository?.();
|
||||
return;
|
||||
}
|
||||
// workspace
|
||||
if (from === "url") onToggleGitHubUrl?.();
|
||||
else if (from === "scratch") onToggleNoRepository?.();
|
||||
}
|
||||
|
||||
function ModeButton({
|
||||
label,
|
||||
mode,
|
||||
active,
|
||||
onSelect,
|
||||
}: {
|
||||
label: string;
|
||||
mode: SourceMode;
|
||||
active: SourceMode;
|
||||
onSelect: (m: SourceMode) => void;
|
||||
}) {
|
||||
const isActive = active === mode;
|
||||
// The previous design used a single text-link with `data-testid="toggle-github-url"`
|
||||
// and the e2e suite still selects by that testid; keep it as an alias on the
|
||||
// URL-mode button so create-task-github-url.spec.ts and subtask.spec.ts keep
|
||||
// passing without a sweep across all e2e files. The clicks are "enter URL mode"
|
||||
// — semantics unchanged.
|
||||
const legacyTestId = mode === "url" ? "toggle-github-url" : undefined;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
data-testid={legacyTestId ?? `source-mode-${mode}`}
|
||||
onClick={() => onSelect(mode)}
|
||||
className={cn(
|
||||
"rounded-sm px-2 py-0.5 text-[11px] font-medium transition-colors cursor-pointer",
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,33 @@ function nonWorktreeDisabledReason(profile: ExecutorProfile): string | null {
|
||||
if ((profile.executor_type ?? "") === "worktree") return null;
|
||||
return "Multi-repo tasks only support the git-worktree executor.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Worktree executor needs a repository to create the worktree from. Disable
|
||||
* it when the task is in no-repository mode so the picker doesn't offer an
|
||||
* unworkable choice (the backend would silently fall back to local).
|
||||
*/
|
||||
function worktreeDisabledReason(profile: ExecutorProfile): string | null {
|
||||
if ((profile.executor_type ?? "") !== "worktree") return null;
|
||||
return "Worktree executor requires a repository.";
|
||||
}
|
||||
|
||||
/**
|
||||
* Combines the two executor-disable rules into a single resolver:
|
||||
* - no-repository mode → disable worktree (it needs a repo)
|
||||
* - multi-repo selection → disable everything except worktree
|
||||
* - otherwise → no disabling
|
||||
* The two never co-occur (no-repository implies zero repos, so multi-repo
|
||||
* cannot be true at the same time), so a simple priority order is enough.
|
||||
*/
|
||||
function pickExecutorDisabledReason(
|
||||
noRepository: boolean,
|
||||
isMultiRepoSelection: boolean,
|
||||
): ((profile: ExecutorProfile) => string | null) | undefined {
|
||||
if (noRepository) return worktreeDisabledReason;
|
||||
if (isMultiRepoSelection) return nonWorktreeDisabledReason;
|
||||
return undefined;
|
||||
}
|
||||
import type {
|
||||
StepType,
|
||||
TaskCreateDialogInitialValues,
|
||||
@@ -64,6 +91,8 @@ type FormResetters = {
|
||||
setDiscoverReposLoaded: (v: boolean) => void;
|
||||
setUseGitHubUrl: (v: boolean) => void;
|
||||
setGitHubUrl: (v: string) => void;
|
||||
setNoRepository: (v: boolean) => void;
|
||||
setWorkspacePath: (v: string) => void;
|
||||
setGitHubBranches: (v: Branch[]) => void;
|
||||
setGitHubUrlError: (v: string | null) => void;
|
||||
setGitHubPrHeadBranch: (v: string | null) => void;
|
||||
@@ -187,6 +216,10 @@ function resetDiscoveryState(resetters: FormResetters, iv?: TaskCreateDialogInit
|
||||
resetters.setGitHubPrHeadBranch(iv?.checkoutBranch ?? null);
|
||||
resetters.setFreshBranchEnabled(false);
|
||||
resetters.setCurrentLocalBranch("");
|
||||
// Source-mode toggle resets — without these, opening the dialog in "None"
|
||||
// mode and reopening for a different task would land in None mode again.
|
||||
resetters.setNoRepository(false);
|
||||
resetters.setWorkspacePath("");
|
||||
}
|
||||
|
||||
/** Hook to manage draft persistence for task creation dialog */
|
||||
@@ -310,6 +343,11 @@ function useFormStateValues(
|
||||
const [fetchedSteps, setFetchedSteps] = useState<StepType[] | null>(null);
|
||||
const [isCreatingSession, setIsCreatingSession] = useState(false);
|
||||
const [isCreatingTask, setIsCreatingTask] = useState(false);
|
||||
// No-repo mode: when true, the task is created with no repositories. The
|
||||
// optional workspacePath points the agent at an existing host folder; empty
|
||||
// means kandev creates a scratch workspace.
|
||||
const [noRepository, setNoRepository] = useState(false);
|
||||
const [workspacePath, setWorkspacePath] = useState("");
|
||||
return {
|
||||
taskName,
|
||||
setTaskName,
|
||||
@@ -341,6 +379,10 @@ function useFormStateValues(
|
||||
currentDefaults,
|
||||
setCurrentDefaults,
|
||||
prevOpenRef,
|
||||
noRepository,
|
||||
setNoRepository,
|
||||
workspacePath,
|
||||
setWorkspacePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -404,6 +446,8 @@ export function useDialogFormState(
|
||||
setGitHubPrHeadBranch: ghUrl.setGitHubPrHeadBranch,
|
||||
setFreshBranchEnabled: freshBranch.setFreshBranchEnabled,
|
||||
setCurrentLocalBranch: freshBranch.setCurrentLocalBranch,
|
||||
setNoRepository: form.setNoRepository,
|
||||
setWorkspacePath: form.setWorkspacePath,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -476,9 +520,10 @@ export function useDialogComputed({
|
||||
? workspaces.find((ws: Workspace) => ws.id === workspaceId)
|
||||
: null;
|
||||
// The form has a repo selection when either: (a) any chip in the unified
|
||||
// list has a repo set, or (b) URL mode has a non-empty URL. The chip row
|
||||
// takes care of branch state per row; there is no global branch.
|
||||
// list has a repo set, (b) URL mode has a non-empty URL, or (c) the task
|
||||
// is intentionally repo-less (noRepository toggle on).
|
||||
const hasRepositorySelection = Boolean(
|
||||
fs.noRepository ||
|
||||
fs.repositories.some((r) => r.repositoryId || r.localPath) ||
|
||||
(fs.useGitHubUrl && fs.githubUrl.trim()),
|
||||
);
|
||||
@@ -503,7 +548,7 @@ export function useDialogComputed({
|
||||
const selectedRepoCount = fs.repositories.filter((r) => r.repositoryId || r.localPath).length;
|
||||
const isMultiRepoSelection = selectedRepoCount > 1;
|
||||
const executorProfileOptions = useExecutorProfileOptions(allExecutorProfiles, {
|
||||
disabledReasonFor: isMultiRepoSelection ? nonWorktreeDisabledReason : undefined,
|
||||
disabledReasonFor: pickExecutorDisabledReason(fs.noRepository, isMultiRepoSelection),
|
||||
});
|
||||
const executorHint = useExecutorHint(executors, fs.executorId, selectedRepoCount);
|
||||
const isLocalExecutor = useIsLocalExecutor(executors, fs.executorId);
|
||||
|
||||
@@ -63,6 +63,8 @@ export function useTaskSubmitHandlers({
|
||||
freshBranchEnabled,
|
||||
isLocalExecutor,
|
||||
repositoryLocalPath,
|
||||
noRepository,
|
||||
workspacePath,
|
||||
transformDescriptionBeforeSubmit,
|
||||
}: SubmitHandlersDeps) {
|
||||
const router = useRouter();
|
||||
@@ -92,8 +94,9 @@ export function useTaskSubmitHandlers({
|
||||
repositories,
|
||||
githubUrl,
|
||||
agentProfileId,
|
||||
noRepository,
|
||||
}),
|
||||
[workspaceId, effectiveWorkflowId, repositories, githubUrl, agentProfileId],
|
||||
[workspaceId, effectiveWorkflowId, repositories, githubUrl, agentProfileId, noRepository],
|
||||
);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
@@ -121,8 +124,9 @@ export function useTaskSubmitHandlers({
|
||||
]);
|
||||
|
||||
const getRepositoriesPayload = useCallback(
|
||||
(consentedDirtyFiles: string[] = []) =>
|
||||
buildRepositoriesPayload({
|
||||
(consentedDirtyFiles: string[] = []) => {
|
||||
if (noRepository) return [];
|
||||
return buildRepositoriesPayload({
|
||||
useGitHubUrl,
|
||||
githubUrl,
|
||||
githubBranch,
|
||||
@@ -132,10 +136,12 @@ export function useTaskSubmitHandlers({
|
||||
workspaceRepositories,
|
||||
isLocalExecutor,
|
||||
freshBranch: buildFreshBranchPayload(consentedDirtyFiles),
|
||||
}),
|
||||
});
|
||||
},
|
||||
// buildFreshBranchPayload is a closure over current scope; lint exception kept narrow.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
noRepository,
|
||||
useGitHubUrl,
|
||||
githubUrl,
|
||||
githubBranch,
|
||||
@@ -302,6 +308,11 @@ export function useTaskSubmitHandlers({
|
||||
planMode: opts.planMode,
|
||||
attachments: opts.attachments,
|
||||
parentId: parentTaskId,
|
||||
// Pass undefined (not "") for an empty trimmed path so the JSON
|
||||
// payload omits the key entirely — matches the noRepository=false
|
||||
// case and keeps "no path provided" semantically distinct from
|
||||
// "empty path string" on the wire.
|
||||
workspacePath: noRepository ? workspacePath.trim() || undefined : undefined,
|
||||
});
|
||||
const taskResponse = await createTaskWithFreshBranchRetry(buildPayload, opts.consented);
|
||||
if (!taskResponse) return;
|
||||
@@ -329,6 +340,8 @@ export function useTaskSubmitHandlers({
|
||||
executorProfileId,
|
||||
isPassthroughProfile,
|
||||
parentTaskId,
|
||||
noRepository,
|
||||
workspacePath,
|
||||
onSuccess,
|
||||
onOpenChange,
|
||||
clearDraft,
|
||||
@@ -509,6 +522,7 @@ export function useTaskSubmitHandlers({
|
||||
executorId,
|
||||
executorProfileId,
|
||||
withAgent: false,
|
||||
workspacePath: noRepository ? workspacePath.trim() || undefined : undefined,
|
||||
});
|
||||
p.workflow_step_id = effectiveDefaultStepId;
|
||||
return p;
|
||||
@@ -535,6 +549,8 @@ export function useTaskSubmitHandlers({
|
||||
effectiveDefaultStepId,
|
||||
executorId,
|
||||
executorProfileId,
|
||||
noRepository,
|
||||
workspacePath,
|
||||
validateForCreate,
|
||||
getRepositoriesPayload,
|
||||
ensureFreshBranchConsent,
|
||||
|
||||
@@ -208,6 +208,12 @@ export type DialogFormState = {
|
||||
/** True while resolving currentLocalBranch — distinguishes "still loading" from "no branch on disk" */
|
||||
currentLocalBranchLoading: boolean;
|
||||
setCurrentLocalBranchLoading: (v: boolean) => void;
|
||||
/** No-repo mode: when true the task is created with no repositories. */
|
||||
noRepository: boolean;
|
||||
setNoRepository: (v: boolean) => void;
|
||||
/** Optional host folder for repo-less tasks; empty means scratch workspace. */
|
||||
workspacePath: string;
|
||||
setWorkspacePath: (v: string) => void;
|
||||
};
|
||||
|
||||
export type SubmitHandlersDeps = {
|
||||
@@ -267,6 +273,10 @@ export type SubmitHandlersDeps = {
|
||||
isLocalExecutor: boolean;
|
||||
/** Resolved on-disk path for the selected repository (workspace or discovered). Empty if not local. */
|
||||
repositoryLocalPath: string;
|
||||
/** When true, the task is created with no repositories (repo-less mode). */
|
||||
noRepository: boolean;
|
||||
/** Optional host folder for repo-less tasks; empty means kandev creates a scratch workspace. */
|
||||
workspacePath: string;
|
||||
/**
|
||||
* Optional async transform applied to the trimmed description before the
|
||||
* API payload is built. Used by feature wrappers (e.g. Improve Kandev) to
|
||||
|
||||
@@ -126,6 +126,8 @@ type DialogFormBodyProps = {
|
||||
onToggleGitHubUrl?: () => void;
|
||||
onGitHubUrlChange: (v: string) => void;
|
||||
onToggleFreshBranch: (enabled: boolean) => void;
|
||||
onToggleNoRepository: () => void;
|
||||
onWorkspacePathChange: (value: string) => void;
|
||||
enhance?: { onEnhance: () => void; isLoading: boolean; isConfigured: boolean };
|
||||
workflowAgentLocked: boolean;
|
||||
/** Workspace repositories — driven into the chip row for repo + branch picks. */
|
||||
@@ -148,6 +150,12 @@ type DialogFormBodyProps = {
|
||||
descriptionPlaceholder?: string;
|
||||
};
|
||||
|
||||
function computeHasAllBranches(fs: DialogFormState): boolean {
|
||||
if (fs.noRepository) return true;
|
||||
if (fs.useGitHubUrl) return !!fs.githubBranch;
|
||||
return fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch);
|
||||
}
|
||||
|
||||
function CreateModeBody(props: DialogFormBodyProps) {
|
||||
const {
|
||||
isCreateMode,
|
||||
@@ -193,6 +201,8 @@ function CreateModeBody(props: DialogFormBodyProps) {
|
||||
freshBranchEnabled={fs.freshBranchEnabled}
|
||||
onToggleFreshBranch={onToggleFreshBranch}
|
||||
isLocalExecutor={isLocalExecutor}
|
||||
onToggleNoRepository={props.onToggleNoRepository}
|
||||
onWorkspacePathChange={props.onWorkspacePathChange}
|
||||
/>
|
||||
{showTaskName && (
|
||||
<InlineTaskName
|
||||
@@ -398,6 +408,8 @@ function useSubmitHandlersWiring({
|
||||
freshBranchEnabled: fs.freshBranchEnabled,
|
||||
isLocalExecutor: computed.isLocalExecutor,
|
||||
repositoryLocalPath,
|
||||
noRepository: fs.noRepository,
|
||||
workspacePath: fs.workspacePath,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -547,6 +559,8 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
}
|
||||
onGitHubUrlChange={handlers.handleGitHubUrlChange}
|
||||
onToggleFreshBranch={handlers.handleToggleFreshBranch}
|
||||
onToggleNoRepository={handlers.handleToggleNoRepository}
|
||||
onWorkspacePathChange={handlers.handleWorkspacePathChange}
|
||||
enhance={setup.enhance}
|
||||
workflowAgentLocked={computed.workflowAgentLocked}
|
||||
repositories={repositories}
|
||||
@@ -569,11 +583,7 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
hasTitle={fs.hasTitle}
|
||||
hasDescription={fs.hasDescription}
|
||||
hasRepositorySelection={computed.hasRepositorySelection}
|
||||
hasAllBranches={
|
||||
fs.useGitHubUrl
|
||||
? !!fs.githubBranch
|
||||
: fs.repositories.length > 0 && fs.repositories.every((r) => !!r.branch)
|
||||
}
|
||||
hasAllBranches={computeHasAllBranches(fs)}
|
||||
agentProfileId={computed.effectiveAgentProfileId}
|
||||
workspaceId={workspaceId}
|
||||
effectiveWorkflowId={computed.effectiveWorkflowId ?? null}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { usePlanPanelAutoOpen } from "@/hooks/use-plan-panel-auto-open";
|
||||
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
|
||||
import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
|
||||
import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
|
||||
import { useActiveTaskHasRepos } from "@/hooks/domains/kanban/use-active-task-has-repos";
|
||||
|
||||
// Panel components (rendered via portals, not directly by dockview)
|
||||
import { TaskSessionSidebar } from "./task-session-sidebar";
|
||||
@@ -309,6 +310,18 @@ function ChangesContent({ panelId }: { panelId: string }) {
|
||||
const fileCount = gitStatus?.files ? Object.keys(gitStatus.files).length : 0;
|
||||
const totalCount = fileCount + commits.length;
|
||||
|
||||
// Repo-less tasks have no git changes ever — auto-close the panel so users
|
||||
// don't see a permanently empty Changes tab. Gate on a confirmed `false`:
|
||||
// `null` means the task hasn't loaded yet, and removing the panel during
|
||||
// that window is unrecoverable in the same session.
|
||||
const taskHasRepos = useActiveTaskHasRepos();
|
||||
useEffect(() => {
|
||||
if (taskHasRepos !== false) return;
|
||||
const dockApi = useDockviewStore.getState().api;
|
||||
const panel = dockApi?.getPanel(panelId);
|
||||
if (dockApi && panel) dockApi.removePanel(panel);
|
||||
}, [taskHasRepos, panelId]);
|
||||
|
||||
useEffect(() => {
|
||||
const title = totalCount > 0 ? `Changes (${totalCount})` : "Changes";
|
||||
setPanelTitle(panelId, title);
|
||||
|
||||
@@ -41,15 +41,7 @@ export function useSubtaskFormState(): DialogFormState {
|
||||
|
||||
return useMemo<DialogFormState>(
|
||||
() => ({
|
||||
// Title is owned by the subtask dialog directly — these are inert.
|
||||
taskName: "",
|
||||
setTaskName: NOOP,
|
||||
hasTitle: false,
|
||||
setHasTitle: NOOP,
|
||||
hasDescription: false,
|
||||
setHasDescription: NOOP,
|
||||
draftDescription: "",
|
||||
openCycle: 0,
|
||||
...INERT_TITLE_DRAFT,
|
||||
currentDefaults: EMPTY_DEFAULTS,
|
||||
descriptionInputRef,
|
||||
// Repo chip row — what RepoChipsRow + useDialogHandlers actually drive.
|
||||
@@ -96,14 +88,7 @@ export function useSubtaskFormState(): DialogFormState {
|
||||
workflowAgentProfileId: "",
|
||||
setWorkflowAgentProfileId: NOOP,
|
||||
clearDraft: NOOP,
|
||||
// Fresh-branch flow is local-executor-only, single-row, and not
|
||||
// surfaced here — keep the toggles wired but always inert.
|
||||
freshBranchEnabled: false,
|
||||
setFreshBranchEnabled: NOOP,
|
||||
currentLocalBranch: "",
|
||||
setCurrentLocalBranch: NOOP,
|
||||
currentLocalBranchLoading: false,
|
||||
setCurrentLocalBranchLoading: NOOP,
|
||||
...INERT_FRESH_BRANCH_AND_NOREPO,
|
||||
}),
|
||||
[
|
||||
repos.repositories,
|
||||
@@ -130,3 +115,33 @@ export function useSubtaskFormState(): DialogFormState {
|
||||
const NOOP = () => undefined;
|
||||
const EMPTY_DEFAULTS = { name: "", description: "" };
|
||||
const EMPTY_STEPS: StepType[] | null = null;
|
||||
|
||||
// Title / draft / openCycle are inert in the subtask flow — the dialog renders
|
||||
// its own title input directly and doesn't restore drafts. Extracted so the
|
||||
// useMemo body stays under the function-length lint cap.
|
||||
const INERT_TITLE_DRAFT = {
|
||||
taskName: "",
|
||||
setTaskName: NOOP,
|
||||
hasTitle: false,
|
||||
setHasTitle: NOOP,
|
||||
hasDescription: false,
|
||||
setHasDescription: NOOP,
|
||||
draftDescription: "",
|
||||
openCycle: 0,
|
||||
} as const;
|
||||
|
||||
// Fresh-branch (local-executor-only) and no-repo / scratch workspace mode are
|
||||
// top-level create-task features; subtasks inherit their parent's repo
|
||||
// context, so these are kept inert.
|
||||
const INERT_FRESH_BRANCH_AND_NOREPO = {
|
||||
freshBranchEnabled: false,
|
||||
setFreshBranchEnabled: NOOP,
|
||||
currentLocalBranch: "",
|
||||
setCurrentLocalBranch: NOOP,
|
||||
currentLocalBranchLoading: false,
|
||||
setCurrentLocalBranchLoading: NOOP,
|
||||
noRepository: false,
|
||||
setNoRepository: NOOP,
|
||||
workspacePath: "",
|
||||
setWorkspacePath: NOOP,
|
||||
} as const;
|
||||
|
||||
@@ -897,18 +897,26 @@ test.describe("Task creation from GitHub URL", () => {
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
|
||||
// Initially the toggle says "or paste a GitHub URL"
|
||||
const toggleBtn = testPage.getByTestId("toggle-github-url");
|
||||
await expect(toggleBtn).toHaveText("or paste a GitHub URL");
|
||||
// The source-mode segmented control: Repo (workspace), URL, None (scratch).
|
||||
// The URL button keeps the legacy `toggle-github-url` testid for backward
|
||||
// compat with the rest of this suite.
|
||||
const urlModeBtn = testPage.getByTestId("toggle-github-url");
|
||||
const repoModeBtn = testPage.getByTestId("source-mode-workspace");
|
||||
|
||||
// Toggle to GitHub URL mode
|
||||
await toggleBtn.click();
|
||||
await expect(testPage.getByTestId("github-url-input")).toBeVisible();
|
||||
await expect(toggleBtn).toHaveText("use a workspace repository");
|
||||
|
||||
// Toggle back to repository selector
|
||||
await toggleBtn.click();
|
||||
// Default state: workspace mode, URL input not rendered.
|
||||
await expect(repoModeBtn).toHaveAttribute("aria-checked", "true");
|
||||
await expect(testPage.getByTestId("github-url-input")).not.toBeVisible();
|
||||
await expect(toggleBtn).toHaveText("or paste a GitHub URL");
|
||||
|
||||
// Switch to URL mode — input becomes visible, URL button is selected.
|
||||
await urlModeBtn.click();
|
||||
await expect(testPage.getByTestId("github-url-input")).toBeVisible();
|
||||
await expect(urlModeBtn).toHaveAttribute("aria-checked", "true");
|
||||
await expect(repoModeBtn).toHaveAttribute("aria-checked", "false");
|
||||
|
||||
// Switch back to workspace mode — input disappears.
|
||||
await repoModeBtn.click();
|
||||
await expect(testPage.getByTestId("github-url-input")).not.toBeVisible();
|
||||
await expect(repoModeBtn).toHaveAttribute("aria-checked", "true");
|
||||
await expect(urlModeBtn).toHaveAttribute("aria-checked", "false");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useTask } from "@/hooks/use-task";
|
||||
|
||||
/**
|
||||
* Repo-status of the active task, distinguishing three states:
|
||||
* - `null` — unknown (no active task, or task hasn't loaded into the
|
||||
* store yet); callers must not act on this.
|
||||
* - `true` — task has at least one repository.
|
||||
* - `false` — task is confirmed repo-less.
|
||||
*
|
||||
* Returning a boolean would conflate "loading" with "no repos", which would
|
||||
* cause auto-close effects (e.g. the Changes panel) to tear panels down on
|
||||
* first render before the task data arrives — `removePanel` is permanent
|
||||
* within a session, so the user couldn't recover them.
|
||||
*/
|
||||
export function useActiveTaskHasRepos(): boolean | null {
|
||||
const taskId = useAppStore((s) => s.tasks.activeTaskId);
|
||||
const task = useTask(taskId);
|
||||
if (!task) return null;
|
||||
return Boolean(task.repositories && task.repositories.length > 0);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { fetchJson, type ApiRequestOptions } from "../client";
|
||||
|
||||
export type DirectoryEntry = {
|
||||
name: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export type DirectoryListing = {
|
||||
path: string;
|
||||
parent: string;
|
||||
entries: DirectoryEntry[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists immediate subdirectories of `path`. When path is empty the backend
|
||||
* defaults to $HOME. Hidden directories (starting with ".") are excluded.
|
||||
* Used by the folder picker for repo-less tasks.
|
||||
*/
|
||||
export async function listDirectory(path: string, options?: ApiRequestOptions) {
|
||||
const qs = path ? `?path=${encodeURIComponent(path)}` : "";
|
||||
return fetchJson<DirectoryListing>(`/api/v1/fs/list-dir${qs}`, options);
|
||||
}
|
||||
@@ -73,6 +73,7 @@ export async function createTask(
|
||||
plan_mode?: boolean;
|
||||
attachments?: Array<{ type: string; data: string; mime_type: string; name?: string }>;
|
||||
parent_id?: string;
|
||||
workspace_path?: string;
|
||||
},
|
||||
options?: ApiRequestOptions,
|
||||
) {
|
||||
|
||||
@@ -9,3 +9,4 @@ See `.agents/skills/spec/SKILL.md` for the workflow.
|
||||
| [external-mcp](external-mcp/spec.md) | draft | 2026-04-28 | — |
|
||||
| [improve-kandev](./improve-kandev/spec.md) | draft | 2026-04-29 | — |
|
||||
| [slack-integration](./slack-integration/spec.md) | draft | 2026-05-02 | — |
|
||||
| [tasks-without-repositories](./tasks-without-repositories/spec.md) | draft | 2026-05-05 | — |
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
# Tasks Without Repositories — Implementation Plan
|
||||
|
||||
**Spec:** `./spec.md`
|
||||
|
||||
## Approach
|
||||
|
||||
The model and schema already support repo-less tasks (`Task.Repositories` is a slice; `task_sessions.repository_id` is nullable). The lifecycle manager already knows how to launch agents in a non-git workspace via the existing `quick-chat` path (`manager_launch.go:182`, which uses `~/.kandev/data/quick-chat/{sessionID}`).
|
||||
|
||||
A repo-less task can launch in one of two workspace modes:
|
||||
|
||||
- **Scratch mode** — kandev creates an empty per-task directory at `~/.kandev/data/scratch/<task-id>/`. Used for any executor when no folder is picked.
|
||||
- **Picked-folder mode** — the user supplies a host path (e.g. `~/Documents/research/`); the agent's working directory is set to that path verbatim. Local executors only.
|
||||
|
||||
The picked path needs to persist across sessions of the same task, so we add a nullable `workspace_path` column on `task_sessions` (or a per-task field — TBD in implementation; simplest is per-session because that's where workspace path already lives).
|
||||
|
||||
The rest of the work is UX exposure plus a few guards:
|
||||
|
||||
1. Backend: persist the picked workspace path; resolve it in `launchResolveWorkspacePath`.
|
||||
2. Backend: skip the `WorktreePreparer` and the repo-cloning prepare scripts when no repo is attached.
|
||||
3. Backend: force the executor to `local` when the workspace default is `worktree` and the task has no repo. Reject non-local executors when a folder was picked.
|
||||
4. Frontend: add a "no repository" path to the create-task dialog with an optional folder picker; hide repo-bound panels when `task.repositories.length === 0`.
|
||||
|
||||
One small schema change (the `workspace_path` column). No new executor type.
|
||||
|
||||
## Backend
|
||||
|
||||
**Schema migration** (`internal/task/repository/sqlite/`)
|
||||
- Add nullable `workspace_path TEXT DEFAULT ''` column to `task_sessions`. Mirror what `repository_id` already does. Migration follows the pattern in `base.go:247-274`.
|
||||
- Expose on the `TaskSession` model in `internal/task/models/models.go`.
|
||||
|
||||
**Resolve workspace path** (`internal/agent/lifecycle/manager_launch.go`)
|
||||
- Update `launchResolveWorkspacePath()`:
|
||||
1. If session has a `WorkspacePath` set (picked-folder mode) → use it directly. Validate it exists and is a directory; reject if missing.
|
||||
2. Else if task has no repos → use scratch dir `~/.kandev/data/scratch/<taskID>/` (create if missing). Note: `<taskID>` not `<sessionID>` so the scratch persists across sessions of the same task — diverges from current `quick-chat/{sessionID}` convention; confirm that's the right call.
|
||||
3. Else → existing repo-bound logic.
|
||||
- Unit-test all three branches.
|
||||
|
||||
**Worktree preparer** (`internal/agent/lifecycle/env_preparer_worktree.go:33-120`)
|
||||
- Already validates `req.RepositoryPath` and returns early. No change — but cover with a test that asserts a no-repo `EnvPrepareRequest` skips the preparer cleanly.
|
||||
|
||||
**Default scripts** (`internal/agent/lifecycle/default_scripts.go`)
|
||||
- The `local` and `worktree` scripts reference `{{repository.clone_url}}`, `{{repository.setup_script}}`. For repo-less tasks, return an empty/no-op prepare script. Cleanest: in the script resolver, short-circuit to `""` when the repo context is empty.
|
||||
- File to touch: `internal/scriptengine/` (placeholder resolver) — return empty string for the whole script when all `{{repository.*}}` placeholders resolve to empty *and* the script is the default. Or simpler: in `manager_launch.go`, detect no-repo and skip prepare-script execution entirely.
|
||||
- Pick the latter — explicit branch in lifecycle manager, no resolver changes.
|
||||
|
||||
**Executor fallback / restriction** (`internal/agent/lifecycle/manager.go` + `manager_launch.go`)
|
||||
- If the resolved executor type is `worktree` and the task has zero repos, downgrade to `local`. Log a warning.
|
||||
- If `WorkspacePath` is set (picked-folder mode), require executor type `local` / `local_pc`. Reject other executor types at launch time with a clear error — the dialog should already prevent this, but enforce server-side too.
|
||||
|
||||
**Service validation** (`internal/task/service/service_tasks.go:102-154`)
|
||||
- `createTaskRepositories()` already no-ops on empty slice — confirmed. No change.
|
||||
|
||||
**HTTP / WS handlers**
|
||||
- Accept an optional `workspace_path` field in the create-task request alongside `repositories`. Pipe it through to the first session.
|
||||
- Verify no handler rejects an empty `repositories` array on create. Add a service test creating a task with `repositories: nil` (with and without `workspace_path`).
|
||||
|
||||
**Folder picker endpoint** (new)
|
||||
- `GET /api/v1/fs/list-dir?path=<abs-path>` — returns immediate subdirectories of `path`. Used by the frontend folder picker.
|
||||
- Default starting path: `$HOME`. Refuse to traverse outside it unless an absolute path is explicitly supplied. Return entries with `name` + `is_dir` only.
|
||||
- Check whether kandev's existing repo-add flow has a similar endpoint to reuse instead of creating a new one.
|
||||
|
||||
## Frontend
|
||||
|
||||
**Create-task dialog** (`apps/web/components/task-create-dialog.tsx` + `task-create-dialog-repo-chips.tsx`)
|
||||
- Add a "No repository" toggle/option above the chips row. When active:
|
||||
- Hide the chip row.
|
||||
- Reveal an optional folder picker (collapsible: "Start in a folder…").
|
||||
- Submit `repositories: []` and the picked `workspace_path` (or empty string) in the create payload.
|
||||
- When the workspace has at least one repo, default the toggle off.
|
||||
- When the workspace has zero repos connected, default the toggle on.
|
||||
- When a folder is picked, hide non-local executors from the executor picker (Docker/Sprites/remote not available).
|
||||
- When no folder is picked, the executor picker behaves normally (minus `worktree`).
|
||||
|
||||
**Task detail / repo-bound panels**
|
||||
- `apps/web/components/github/pr-detail-panel.tsx` — early-return when `task.repositories?.length === 0` (renders nothing, not an empty state).
|
||||
- Diff viewer, branch picker, git status panels — same conditional. Find each via grep for usages of `task.repositories` and `gitStatus.bySessionId`.
|
||||
- Centralize the check: a small `useTaskHasRepo(taskId)` hook in `hooks/domains/kanban/`.
|
||||
|
||||
**Folder picker component** (new — `apps/web/components/fs/folder-picker.tsx`)
|
||||
- Modal or popover. Lists directories from the new `/api/v1/fs/list-dir` endpoint, starting at `$HOME`. Click to descend, breadcrumbs for ascent, "Choose this folder" button to commit.
|
||||
- Reuse if a similar component already exists (check `apps/web/components/` for repo-add flow).
|
||||
|
||||
**Executor picker**
|
||||
- When no repo is selected and no folder is picked, hide `worktree`.
|
||||
- When a folder is picked, hide all non-local executors.
|
||||
|
||||
**Kanban**
|
||||
- Treat repo-less tasks identically (per spec). No badge, no icon. Verify nothing in `apps/web/app/tasks/columns.tsx` crashes on `task.repositories?.[0] === undefined` — it already uses optional chaining (line 39-40), so should be safe.
|
||||
|
||||
## Phases
|
||||
|
||||
1. **Schema + workspace resolution.** Add `task_sessions.workspace_path` migration; update `launchResolveWorkspacePath` for the three branches (picked folder / scratch / repo); unit-test each branch.
|
||||
2. **Backend guards.** Skip prepare-script when no repo; executor fallback `worktree`→`local`; reject non-local executors when `workspace_path` is set.
|
||||
3. **Folder-picker endpoint.** `/api/v1/fs/list-dir`; or reuse existing if found.
|
||||
4. **Frontend create flow.** Toggle in create-task dialog; folder picker component; submit empty `repositories` + optional `workspace_path`.
|
||||
5. **Frontend conditional UI.** `useTaskHasRepo` hook; gate PR panel, diff viewer, git status, branch picker, executor picker.
|
||||
6. **E2E.** Two Playwright tests:
|
||||
- Repo-less + scratch: create task → agent launches → verify no diff/PR UI.
|
||||
- Repo-less + picked folder: create task pointing at a temp dir → agent reads/writes file there.
|
||||
7. **Verify.** `make fmt` then `make typecheck test lint` (backend); `pnpm typecheck test lint` (web).
|
||||
|
||||
## Risks / open points
|
||||
|
||||
- **Picked-folder validation.** Symlinks, missing paths, paths inside `$HOME/.kandev/`, paths containing existing kandev tasks — what do we reject? v1: require absolute path, require it to exist as a directory, no other restrictions. Trust the user.
|
||||
- **Persistence of picked path across sessions.** Picked path needs to survive task restart. `task_sessions.workspace_path` per-session works if we copy it from the prior session on resume. Per-task `Task.workspace_path` is simpler but a slightly bigger schema change. Pick one in implementation.
|
||||
- **Quick-chat vs scratch namespace.** Existing `quick-chat/{sessionID}` is per-session; new scratch per-task `scratch/{taskID}` diverges. Decide: rename quick-chat to scratch and unify, or keep both with clear semantics?
|
||||
- **Docker / Sprites scratch dirs.** `local_docker` will need to bind-mount the host scratch dir into the container; sprites needs an internal scratch dir. Check `default_scripts.go` Docker/Sprites scripts to confirm — may need a no-repo variant for each. If scope grows, restrict v1 to `local` only and defer Docker/Sprites to v2.
|
||||
- **Workspace cleanup.** Today nothing seems to clean up `quick-chat/{sessionID}` dirs. If we add `scratch/{taskID}`, accumulate even more cruft. Out of scope to fix here, but worth a follow-up issue.
|
||||
|
||||
## Out of scope (per spec)
|
||||
|
||||
Promoting a repo-less task to repo-bound after creation; detaching repos from existing tasks; cross-task scratch dirs; visual badges on the kanban; alternative tool surfaces.
|
||||
@@ -0,0 +1,46 @@
|
||||
---
|
||||
status: draft
|
||||
created: 2026-05-05
|
||||
owner: tbd
|
||||
---
|
||||
|
||||
# Tasks Without Repositories
|
||||
|
||||
## Why
|
||||
|
||||
Not every piece of agent work is tied to a codebase. Users want to brainstorm a design, ask a research question, summarize a Linear ticket, or run a one-off shell command without picking a repo first — and today the create-task flow forces a repo selection. The repo requirement also blocks new users who haven't connected a workspace yet from trying the product. Utility agents (slack triage) already prove the agent-launch path works without a repo; this feature exposes the same capability through the normal task UI.
|
||||
|
||||
## What
|
||||
|
||||
- Users can create a task with no repository attached. The repo selector in the create-task dialog is optional, with a clear "no repository" path.
|
||||
- When choosing "no repository", the user can optionally pick a starting folder on their machine. The agent launches in that folder as its working directory. Files the agent reads, writes, and runs commands against are the user's real files — kandev does not clone, copy, or git-manage the directory.
|
||||
- If no folder is picked, the task gets a fresh scratch workspace owned by kandev — empty, no git, persists for the task's lifetime so the agent can write files and reference its own outputs across turns.
|
||||
- Repo-dependent UI (diff viewer, branch picker, git status, PR creation, worktree controls) is hidden — not greyed out — for repo-less tasks.
|
||||
- Repo-less tasks use the workspace's default executor. The `worktree` executor is unavailable for them (it inherently needs a repo); if the workspace default is `worktree`, fall back to `local`.
|
||||
- Picking a starting folder is a local-executor-only feature. Container-based executors (`local_docker`, `remote_docker`, `sprites`) do not expose the folder picker — they always get a fresh scratch workspace.
|
||||
- The MCP / tool surface is unchanged — repo-less tasks get the same tools as repo-bound tasks. The agent ignores git-specific tools that don't apply.
|
||||
- Existing repo-bound tasks are unaffected. The repo selector remains the default in the create-task dialog.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** a user opens the create-task dialog, **WHEN** they choose "no repository" without picking a folder and submit a title, **THEN** the task is created with an empty `Repositories` slice, the agent launches in a fresh scratch workspace, and the task detail view hides all git/diff/PR panels.
|
||||
- **GIVEN** a user chooses "no repository" and picks `~/Documents/research/` as the starting folder, **WHEN** they submit the task, **THEN** the agent launches with `~/Documents/research/` as its working directory and can read existing files there.
|
||||
- **GIVEN** a repo-less task with no folder picked is running, **WHEN** the agent writes a file (e.g. `notes.md`) and the user sends a follow-up turn, **THEN** the file is still present in the scratch workspace and the agent can read it back.
|
||||
- **GIVEN** a repo-less task, **WHEN** the user opens the executor picker, **THEN** the `worktree` option is hidden or disabled with a tooltip explaining it requires a repository.
|
||||
- **GIVEN** a user creates a repo-less task in a workspace whose default executor is `worktree`, **WHEN** the task launches, **THEN** the executor falls back to `local` and the task starts normally.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Attaching a repo to a repo-less task after creation (no "promote to repo task" flow in v1).
|
||||
- Detaching repos from existing repo-bound tasks.
|
||||
- Cross-task scratch workspaces or shared scratch dirs — each repo-less task without a picked folder gets its own isolated scratch workspace, wiped when the task is deleted.
|
||||
- Sandboxing or write-protection of a user-picked folder. The agent has full read/write access to whatever the user picks — same trust model as running an editor against the folder.
|
||||
- A folder picker for container-based executors. Mounting arbitrary host paths into Docker/Sprites is out of scope for v1.
|
||||
- Special "quick chat" UX distinct from a normal task (no separate inbox, no ephemeral mode). A repo-less task is a normal task that happens to have zero repos.
|
||||
- Slack `!kandev` triage is unchanged; it continues to use utility agents directly, not the task system.
|
||||
|
||||
## Open questions
|
||||
|
||||
- **Scratch workspace location and lifecycle.** Per-task scratch dir under the kandev data directory (e.g. `~/.kandev/data/scratch/<task-id>/`)? Wiped on task delete, or retained until manual cleanup? Per-executor — does `local_docker` get a named volume, or a tmpfs?
|
||||
- **Folder picker mechanism.** Backend endpoint that lists local directories (browse from `$HOME`)? Or a free-text path input with server-side validation? Does kandev's existing repo-add flow already have a directory browser we can reuse?
|
||||
- **Storage of the picked folder.** New nullable `task_sessions.workspace_path` column, or reuse an existing field?
|
||||
Reference in New Issue
Block a user