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:
João Salavisa
2026-05-12 01:10:22 +01:00
committed by GitHub
parent ed056a22b5
commit 45a46f9899
38 changed files with 1388 additions and 159 deletions
+7
View File
@@ -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
+10 -3
View File
@@ -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,
}
+266
View File
@@ -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
+15 -5
View File
@@ -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);
}
+22
View File
@@ -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);
}
+1
View File
@@ -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,
) {
+1
View File
@@ -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?