Refine external instructions bundle handling

Keep existing instructionsFilePath agents in external-bundle mode during edits, expose legacy promptTemplate as a deprecated virtual file, and reuse the shared PackageFileTree component in the Prompts view.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-17 16:15:45 -05:00
parent 4fdcfe5515
commit 9d452eb120
5 changed files with 175 additions and 57 deletions

View File

@@ -175,49 +175,57 @@ export function PackageFileTree({
onToggleCheck,
renderFileExtra,
fileRowClassName,
showCheckboxes = true,
depth = 0,
}: {
nodes: FileTreeNode[];
selectedFile: string | null;
expandedDirs: Set<string>;
checkedFiles: Set<string>;
checkedFiles?: Set<string>;
onToggleDir: (path: string) => void;
onSelectFile: (path: string) => void;
onToggleCheck: (path: string, kind: "file" | "dir") => void;
onToggleCheck?: (path: string, kind: "file" | "dir") => void;
/** Optional extra content rendered at the end of each file row (e.g. action badge) */
renderFileExtra?: (node: FileTreeNode, checked: boolean) => ReactNode;
/** Optional additional className for file rows */
fileRowClassName?: (node: FileTreeNode, checked: boolean) => string | undefined;
showCheckboxes?: boolean;
depth?: number;
}) {
const effectiveCheckedFiles = checkedFiles ?? new Set<string>();
return (
<div>
{nodes.map((node) => {
const expanded = node.kind === "dir" && expandedDirs.has(node.path);
if (node.kind === "dir") {
const childFiles = collectAllPaths(node.children, "file");
const allChecked = [...childFiles].every((p) => checkedFiles.has(p));
const someChecked = [...childFiles].some((p) => checkedFiles.has(p));
const allChecked = [...childFiles].every((p) => effectiveCheckedFiles.has(p));
const someChecked = [...childFiles].some((p) => effectiveCheckedFiles.has(p));
return (
<div key={node.path}>
<div
className={cn(
"group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
showCheckboxes
? "group grid w-full grid-cols-[auto_minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground"
: "group grid w-full grid-cols-[minmax(0,1fr)_2.25rem] items-center gap-x-1 pr-3 text-left text-sm text-muted-foreground hover:bg-accent/30 hover:text-foreground",
TREE_ROW_HEIGHT_CLASS,
)}
style={{
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
}}
>
<label className="flex items-center pl-2">
<input
type="checkbox"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
onChange={() => onToggleCheck(node.path, "dir")}
className="mr-2 accent-foreground"
/>
</label>
{showCheckboxes && (
<label className="flex items-center pl-2">
<input
type="checkbox"
checked={allChecked}
ref={(el) => { if (el) el.indeterminate = someChecked && !allChecked; }}
onChange={() => onToggleCheck?.(node.path, "dir")}
className="mr-2 accent-foreground"
/>
</label>
)}
<button
type="button"
className="flex min-w-0 items-center gap-2 py-1 text-left"
@@ -249,12 +257,13 @@ export function PackageFileTree({
nodes={node.children}
selectedFile={selectedFile}
expandedDirs={expandedDirs}
checkedFiles={checkedFiles}
checkedFiles={effectiveCheckedFiles}
onToggleDir={onToggleDir}
onSelectFile={onSelectFile}
onToggleCheck={onToggleCheck}
renderFileExtra={renderFileExtra}
fileRowClassName={fileRowClassName}
showCheckboxes={showCheckboxes}
depth={depth + 1}
/>
)}
@@ -263,7 +272,7 @@ export function PackageFileTree({
}
const FileIcon = fileIcon(node.name);
const checked = checkedFiles.has(node.path);
const checked = effectiveCheckedFiles.has(node.path);
const extraClassName = fileRowClassName?.(node, checked);
return (
<div
@@ -278,14 +287,16 @@ export function PackageFileTree({
paddingInlineStart: `${TREE_BASE_INDENT + depth * TREE_STEP_INDENT - 8}px`,
}}
>
<label className="flex items-center pl-2">
<input
type="checkbox"
checked={checked}
onChange={() => onToggleCheck(node.path, "file")}
className="mr-2 accent-foreground"
/>
</label>
{showCheckboxes && (
<label className="flex items-center pl-2">
<input
type="checkbox"
checked={checked}
onChange={() => onToggleCheck?.(node.path, "file")}
className="mr-2 accent-foreground"
/>
</label>
)}
<button
type="button"
className="flex min-w-0 flex-1 items-center gap-2 py-1 text-left"

View File

@@ -29,6 +29,7 @@ import { EntityRow } from "../components/EntityRow";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { PackageFileTree, buildFileTree } from "../components/PackageFileTree";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
@@ -1495,6 +1496,7 @@ function PromptsTab({
entryFile: string;
} | null>(null);
const [newFilePath, setNewFilePath] = useState("");
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
const [awaitingRefresh, setAwaitingRefresh] = useState(false);
const lastFileVersionRef = useRef<string | null>(null);
@@ -1516,8 +1518,17 @@ function PromptsTab({
const currentEntryFile = bundleDraft?.entryFile ?? bundle?.entryFile ?? "AGENTS.md";
const currentRootPath = bundleDraft?.rootPath ?? bundle?.rootPath ?? "";
const fileOptions = bundle?.files.map((file) => file.path) ?? [];
const visibleFilePaths = useMemo(
() => [...new Set([currentEntryFile, ...fileOptions])],
[currentEntryFile, fileOptions],
);
const fileTree = useMemo(
() => buildFileTree(Object.fromEntries(visibleFilePaths.map((filePath) => [filePath, ""]))),
[visibleFilePaths],
);
const selectedOrEntryFile = selectedFile || currentEntryFile;
const selectedFileExists = fileOptions.includes(selectedOrEntryFile);
const selectedFileSummary = bundle?.files.find((file) => file.path === selectedOrEntryFile) ?? null;
const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({
queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile),
@@ -1585,6 +1596,19 @@ function PromptsTab({
}
}, [bundle, selectedFile]);
useEffect(() => {
const nextExpanded = new Set<string>();
for (const filePath of visibleFilePaths) {
const parts = filePath.split("/");
let currentPath = "";
for (let i = 0; i < parts.length - 1; i++) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]!;
nextExpanded.add(currentPath);
}
}
setExpandedDirs(nextExpanded);
}, [visibleFilePaths]);
useEffect(() => {
const versionKey = selectedFileDetail ? `${selectedFileDetail.path}:${selectedFileDetail.content}` : `draft:${selectedOrEntryFile}`;
if (awaitingRefresh) {
@@ -1708,7 +1732,7 @@ function PromptsTab({
{(bundle?.legacyPromptTemplateActive || bundle?.legacyBootstrapPromptTemplateActive) && (
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Legacy inline prompt fields are still active for this agent. The next bundle save will migrate behavior into file-backed instructions and clear those legacy fields.
Legacy inline prompt state is still present. `promptTemplate` now appears as a deprecated virtual file entry so it is visible without masquerading as the live `AGENTS.md`.
</div>
)}
@@ -1827,30 +1851,40 @@ function PromptsTab({
</Button>
))}
</div>
<div className="space-y-1">
{[...new Set([currentEntryFile, ...fileOptions])].map((filePath) => {
const file = bundle?.files.find((entry) => entry.path === filePath);
return (
<button
key={filePath}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm",
filePath === selectedOrEntryFile ? "border-foreground/30 bg-accent/30" : "border-border",
)}
onClick={() => {
setSelectedFile(filePath);
if (!fileOptions.includes(filePath)) setDraft("");
}}
>
<span className="truncate font-mono">{filePath}</span>
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
{file?.isEntryFile ? "entry" : file ? `${file.size}b` : "new"}
</span>
</button>
);
<PackageFileTree
nodes={fileTree}
selectedFile={selectedOrEntryFile}
expandedDirs={expandedDirs}
checkedFiles={new Set()}
onToggleDir={(dirPath) => setExpandedDirs((current) => {
const next = new Set(current);
if (next.has(dirPath)) next.delete(dirPath);
else next.add(dirPath);
return next;
})}
</div>
onSelectFile={(filePath) => {
setSelectedFile(filePath);
if (!fileOptions.includes(filePath)) setDraft("");
}}
onToggleCheck={() => {}}
showCheckboxes={false}
renderFileExtra={(node) => {
const file = bundle?.files.find((entry) => entry.path === node.path);
if (!file) return null;
return (
<span
className={cn(
"ml-3 shrink-0 rounded border px-1.5 py-0.5 text-[10px] uppercase tracking-wide",
file.deprecated
? "border-amber-500/40 bg-amber-500/10 text-amber-200"
: "border-border text-muted-foreground",
)}
>
{file.deprecated ? "deprecated" : file.isEntryFile ? "entry" : `${file.size}b`}
</span>
);
}}
/>
</div>
<div className="border border-border rounded-lg p-4 space-y-3">
@@ -1859,11 +1893,13 @@ function PromptsTab({
<h4 className="text-sm font-medium font-mono">{selectedOrEntryFile}</h4>
<p className="text-xs text-muted-foreground">
{selectedFileExists
? `${selectedFileDetail?.language ?? "text"} file`
? selectedFileSummary?.deprecated
? "Deprecated virtual file"
: `${selectedFileDetail?.language ?? "text"} file`
: "New file in this bundle"}
</p>
</div>
{selectedFileExists && selectedOrEntryFile !== currentEntryFile && (
{selectedFileExists && !selectedFileSummary?.deprecated && selectedOrEntryFile !== currentEntryFile && (
<Button
type="button"
size="sm"