Files
kandev/apps/web/components/task-create-dialog-options.tsx
T
Carlos Florêncio bc218dfbbe feat: add workspace sources from files panel (#1900)
* feat: add workspace sources from files panel

* fix: preserve worktree identity during recovery

* feat: unify workspace source actions

* fix: validate recovered workspace repositories

* fix: harden workspace source filesystem boundaries

* test: split workspace repository chip coverage

* fix: restrict repository rescan recovery paths

* fix: stabilize workspace source CI checks

* test: canonicalize workspace source paths

* test: align workspace source container paths

* fix: populate direct workspace repository names

* test: preserve git fixture rewrites across restarts

* test: isolate backend git fixture config

* fix: resume remote workspace executions before creation

* fix: preserve ssh agent identity during resume

* fix: preserve SSH workspace across backend restart

* fix: simplify workspace actions trigger

* fix: refine add workspace repositories UX

* docs: narrow workspace source UX updates

* fix: avoid phantom turn on session resume

* test(web): normalize workspace source request headers

* feat(web): streamline workspace source picker

* docs: remove trailing spaces from workspace source plan

* fix: prevent resume status from creating phantom turns

* fix(web): stabilize workspace source attachment

* refactor(web): extract workspace source store contract

* fix: avoid phantom turns on idle agent resume

* fix: address backend static analysis findings

* fix: preserve agentctl readiness status value

* fix: preserve resume messages without active turns

* fix: resume agents from promoted workspace root

* chore: merge origin/main into feature/add-new-repo-folder-d97

* feat(web): explain workspace source consequences

* test(websocket): count session activity subscription
2026-07-25 21:29:53 +01:00

263 lines
9.3 KiB
TypeScript

"use client";
import { useMemo } from "react";
import { IconGitBranch, IconTerminal2 } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { ScrollOnOverflow } from "@kandev/ui/scroll-on-overflow";
import type {
LocalRepository,
Repository,
Branch,
Executor,
ExecutorProfile,
} from "@/lib/types/http";
import type { AgentProfileOption } from "@/lib/state/slices";
import { formatUserHomePath, truncateRepoPath } from "@/lib/utils";
import { getExecutorIcon } from "@/lib/executor-icons";
import { AgentLogo } from "@/components/agent-logo";
import { getCapabilityWarning } from "@/lib/capability-warning";
import { buildBranchKeywords } from "./task-create-dialog-pill";
type OptionItem = {
value: string;
label: string;
renderLabel: () => React.ReactNode;
disabled?: boolean;
disabledReason?: string;
};
export function useRepositoryOptions(
repositories: Repository[],
discoveredRepositories: LocalRepository[],
) {
const repositoryOptions = useMemo(() => {
const normalizeRepoPath = (path: string) => path.replace(/\\/g, "/").replace(/\/+$/g, "");
const workspaceRepoPaths = new Set(
repositories
.map((repo: Repository) => repo.local_path)
.filter(Boolean)
.map((path: string) => normalizeRepoPath(path)),
);
const localRepoOptions = discoveredRepositories.filter(
(repo: LocalRepository) => !workspaceRepoPaths.has(normalizeRepoPath(repo.path)),
);
return [
...repositories.map((repo: Repository) => ({
value: repo.id,
label: repo.name,
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
<span className="shrink-0">{repo.name}</span>
{repo.local_path ? (
<Badge
variant="secondary"
className="text-xs text-muted-foreground max-w-[140px] min-w-0 truncate ml-auto"
title={formatUserHomePath(repo.local_path)}
>
{truncateRepoPath(repo.local_path, 24)}
</Badge>
) : null}
</span>
),
})),
...localRepoOptions.map((repo: LocalRepository) => ({
value: repo.path,
label: truncateRepoPath(repo.path, 24),
renderLabel: () => (
<span
className="flex min-w-0 flex-1 items-center overflow-hidden"
title={formatUserHomePath(repo.path)}
>
<span className="truncate">{truncateRepoPath(repo.path, 28)}</span>
</span>
),
})),
];
}, [repositories, discoveredRepositories]);
const headerRepositoryOptions = useMemo(() => {
return repositoryOptions.map((opt) => ({
...opt,
renderLabel: () => <span className="truncate">{opt.label}</span>,
}));
}, [repositoryOptions]);
return { repositoryOptions, headerRepositoryOptions };
}
export function useBranchOptions(branchOptionsRaw: Branch[]) {
return useMemo(() => {
return branchOptionsRaw.map((branchObj: Branch) => {
const displayName =
branchObj.type === "remote" && branchObj.remote
? `${branchObj.remote}/${branchObj.name}`
: branchObj.name;
// Keywords give the scorer extra surfaces to match against: the leaf
// branch name, every path segment, and (for remotes) the remote name.
const keywords = buildBranchKeywords(branchObj.name, branchObj.remote);
return {
value: displayName,
label: displayName,
keywords,
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1.5">
<IconGitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate" title={displayName}>
{displayName}
</span>
</span>
<Badge variant="outline" className="text-xs">
{branchObj.type === "local" ? "local" : branchObj.remote || "remote"}
</Badge>
</span>
),
};
});
}, [branchOptionsRaw]);
}
export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): OptionItem[] {
return useMemo(() => {
return agentProfiles.map((profile: AgentProfileOption) => {
const parts = profile.label.split(" \u2022 ");
const agentLabel = parts[0] ?? profile.label;
const profileLabel = parts[1] ?? "";
const isPassthrough = profile.cli_passthrough === true;
const warning = getCapabilityWarning(profile.capability_status, profile.capability_error);
return {
value: profile.id,
label: profile.label,
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="flex shrink-0 items-center gap-1.5">
<AgentLogo agentName={profile.agent_name} className="shrink-0" />
<span>{agentLabel}</span>
{warning && (
<warning.Icon className={`size-3.5 ${warning.color}`} title={warning.title} />
)}
</span>
<span className="flex shrink-0 items-center gap-1.5">
{isPassthrough && (
<IconTerminal2
className="size-3.5 text-muted-foreground"
title="CLI mode - your prompt will be auto-injected into the terminal"
/>
)}
{profileLabel ? (
<ScrollOnOverflow className="rounded-full border border-border px-2 py-0.5 text-xs">
{profileLabel}
</ScrollOnOverflow>
) : null}
</span>
</span>
),
};
});
}, [agentProfiles]);
}
export function useExecutorOptions(executors: Executor[]): OptionItem[] {
return useMemo(() => {
return executors.map((executor: Executor) => {
const Icon = getExecutorIcon(executor.type);
return {
value: executor.id,
label: executor.name,
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center gap-2">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{executor.name}</span>
</span>
),
};
});
}, [executors]);
}
export function useIsLocalExecutor(executors: Executor[], executorId: string): boolean {
return useMemo(() => {
const selected = executors.find((e: Executor) => e.id === executorId);
return selected?.type === "local" || selected?.type === "local_pc";
}, [executors, executorId]);
}
export function computeExecutorHint(
executors: Executor[],
executorId: string,
repoCount: number,
): string | null {
const selectedExecutor = executors.find((e: Executor) => e.id === executorId);
if (selectedExecutor?.type === "worktree") {
if (repoCount > 1) {
return "A git worktree will be created for each repository in a parent folder. The agent runs in that parent folder so it can see every worktree side by side.";
}
return "A git worktree will be created from the base branch.";
}
if (selectedExecutor?.type === "local_docker" || selectedExecutor?.type === "remote_docker") {
return "A Docker container will be created from the selected base branch and checked out on a task branch.";
}
if (selectedExecutor?.type === "local" || selectedExecutor?.type === "local_pc")
return "The agent will run directly on the repository.";
return null;
}
export function useExecutorHint(
executors: Executor[],
executorId: string,
repoCount: number,
): string | null {
return useMemo(
() => computeExecutorHint(executors, executorId, repoCount),
[executors, executorId, repoCount],
);
}
export type ExecutorProfileOptionItem = OptionItem & {
executorType?: string;
executorName?: string;
};
export type ExecutorProfileOptionsConfig = {
/**
* Returns a tooltip string when the given profile should render disabled.
* Used by the kanban task-create dialog for repository-mode executor
* capability gates.
*/
disabledReasonFor?: (profile: ExecutorProfile) => string | null;
};
export function useExecutorProfileOptions(
allProfiles: ExecutorProfile[],
config?: ExecutorProfileOptionsConfig,
): ExecutorProfileOptionItem[] {
const disabledReasonFor = config?.disabledReasonFor;
return useMemo(() => {
return allProfiles.map((profile) => {
const Icon = getExecutorIcon(profile.executor_type ?? "local");
const disabledReason = disabledReasonFor?.(profile) ?? null;
return {
value: profile.id,
label: profile.name,
executorType: profile.executor_type,
executorName: profile.executor_name,
disabled: !!disabledReason,
disabledReason: disabledReason ?? undefined,
renderLabel: () => (
<span className="flex min-w-0 flex-1 items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1.5">
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{profile.name}</span>
</span>
{profile.executor_name && (
<Badge variant="outline" className="text-xs">
{profile.executor_name}
</Badge>
)}
</span>
),
};
});
}, [allProfiles, disabledReasonFor]);
}