Files
PaperClipAI/ui/src/components/task-chat/ComposerWorktreePicker.tsx
T
DottaandPaperclip 2c43b39167 refactor(ui): share task composer and project/worktree controls (#15091)
Use the shared composer for new tasks, including project and worktree selection, rich mentions and slash commands, and remembered task settings.

Save project preferences after successful task updates. Show known agent models by name and use Default for unknown runtime defaults.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-05 18:06:40 -05:00

110 lines
5.1 KiB
TypeScript

import { useMemo, type CSSProperties } from "react";
import { Folder, GitBranch, GitBranchPlus, Monitor } from "lucide-react";
import type { ExecutionWorkspaceSummary } from "@paperclipai/shared";
import { SearchableSelect, type SearchableSelectOption } from "@/components/SearchableSelect";
import { orderReusableExecutionWorkspaces } from "@/lib/reusable-execution-workspaces";
interface WorktreeOption extends SearchableSelectOption {
mode: string;
workspaceId?: string;
description: string;
}
interface ComposerWorktreePickerProps {
mode: string;
workspaceId: string;
workspaces: readonly ExecutionWorkspaceSummary[];
onChange: (mode: string, workspaceId: string) => void;
loading: boolean;
error: boolean;
onRetry: () => void;
disabled?: boolean;
mobile?: boolean;
contentStyle?: CSSProperties;
selectedWorkspaceLabel?: string;
}
const MODE_OPTIONS: WorktreeOption[] = [
{ key: "isolated_workspace", value: "isolated_workspace", mode: "isolated_workspace", label: "New worktree", description: "Start in an isolated checkout." },
{ key: "shared_workspace", value: "shared_workspace", mode: "shared_workspace", label: "Project workspace", description: "Use the project's shared checkout." },
{ key: "operator_branch", value: "operator_branch", mode: "operator_branch", label: "New branch", description: "Create a branch in the project workspace." },
{ key: "agent_default", value: "agent_default", mode: "agent_default", label: "Agent default", description: "Let the agent choose its workspace." },
{ key: "inherit", value: "inherit", mode: "inherit", label: "Project default", description: "Use the project's worktree settings." },
];
/** Reuses the searchable selector and the existing workspace recency ordering. */
export function ComposerWorktreePicker({
mode, workspaceId, workspaces, onChange, loading, error, onRetry,
disabled, mobile, contentStyle, selectedWorkspaceLabel,
}: ComposerWorktreePickerProps) {
const groups = useMemo(() => [
{ id: "new", options: [MODE_OPTIONS[0]!] },
{
id: "reuse", label: "Reuse a worktree",
options: orderReusableExecutionWorkspaces(workspaces).map((workspace): WorktreeOption => ({
key: `reuse:${workspace.id}`, value: `reuse:${workspace.id}`,
workspaceId: workspace.id, mode: "reuse_existing", label: workspace.name,
description: workspace.branchName ?? "Existing workspace",
searchText: `${workspace.branchName ?? ""} ${workspace.id}`,
})),
},
{
id: "other", label: "Other options",
options: MODE_OPTIONS.filter((option) => option.mode === "shared_workspace"
|| (option.mode !== "isolated_workspace" && option.mode === mode)),
},
], [mode, workspaces]);
const value = mode === "reuse_existing" ? `reuse:${workspaceId}` : mode;
return (
<SearchableSelect<string, WorktreeOption>
value={value}
groups={groups}
onValueChange={(_, option) => onChange(option.mode, option.workspaceId ?? "")}
placeholder="Worktrees"
triggerAriaLabel="Worktrees"
mobileTitle="Worktrees"
searchPlaceholder="Search worktrees..."
emptyMessage="No matching worktrees."
disabled={disabled}
modal={mobile}
contentStyle={contentStyle}
contentWidth="auto"
className="min-w-0 flex-1 sm:flex-none sm:max-w-72"
triggerClassName="h-8 gap-1.5 border-0 bg-transparent px-2 text-xs font-medium shadow-none hover:bg-accent focus-visible:bg-accent focus-visible:ring-0"
renderValue={(option) => (
<span className="flex min-w-0 items-center gap-1.5">
<GitBranch className="size-3.5 shrink-0" aria-hidden />
<span className="truncate">
<span className="hidden text-muted-foreground sm:inline">Worktrees · </span>
{option?.label ?? selectedWorkspaceLabel ?? "Select worktree"}
</span>
</span>
)}
renderOption={(option) => {
const Icon = option.workspaceId ? GitBranch : option.mode === "isolated_workspace" ? GitBranchPlus
: option.mode === "agent_default" ? Monitor : option.mode === "operator_branch" ? GitBranch : Folder;
return (
<>
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex min-w-0 flex-col">
<span className="truncate">{option.label}</span>
<span className="truncate text-xs text-muted-foreground">{option.description}</span>
</span>
</>
);
}}
listFooter={loading || error || workspaces.length === 0 ? (
<div className="border-t border-border px-3 py-2 text-xs text-muted-foreground" role="status">
{loading ? "Loading existing worktrees…" : error ? (
<span className="flex items-center justify-between gap-2">
Couldn't load worktrees.
<button type="button" className="rounded px-2 py-1 text-foreground hover:bg-accent" onClick={onRetry}>Retry</button>
</span>
) : "No existing worktrees yet. Choose New worktree to start one."}
</div>
) : undefined}
/>
);
}