diff --git a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentCodeNode.cs b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentCodeNode.cs index 04577032c..6a6e629e9 100644 --- a/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentCodeNode.cs +++ b/backend/src/CodeSpace.Core/Services/Workflows/Nodes/Builtin/AgentCodeNode.cs @@ -661,7 +661,7 @@ private static bool TryReadModelCredentialModelId(NodeRunContext context, out Gu /// Read the optional baseRef input — the branch/ref to clone the primary repo at (session branch continuity). Absent / blank / non-string → null (the repo default). private static string? ReadBaseRef(NodeRunContext context) => context.Inputs.TryGetValue("baseRef", out var v) && v.ValueKind == JsonValueKind.String && !string.IsNullOrWhiteSpace(v.GetString()) - ? v.GetString() + ? v.GetString()!.Trim() : null; /// Read the optional baseRefFromSession input — true ONLY when the launch projection set baseRef from a SESSION-inherited prior branch (a transient branch a merged PR can delete). Marks the primary ref SOFT so the clone falls back to the default branch if it was pruned. An author-pinned baseRef never carries this ⇒ stays HARD (fail loud if gone). Absent / non-true → false. diff --git a/backend/tests/CodeSpace.UnitTests/Workflows/AgentCodeNodeTests.cs b/backend/tests/CodeSpace.UnitTests/Workflows/AgentCodeNodeTests.cs index eeb97aba6..b0f34a4f3 100644 --- a/backend/tests/CodeSpace.UnitTests/Workflows/AgentCodeNodeTests.cs +++ b/backend/tests/CodeSpace.UnitTests/Workflows/AgentCodeNodeTests.cs @@ -167,6 +167,21 @@ public async Task Base_ref_input_clones_the_single_repo_at_that_ref() primary.RefSoftFallback.ShouldBeFalse("a baseRef with NO baseRefFromSession marker is HARD — an author-pinned ref fails loud if gone, never silently rewritten"); } + [Fact] + public async Task Base_ref_input_trims_accidental_surrounding_whitespace() + { + var inputs = new Dictionary + { + ["repositoryId"] = Str(Guid.NewGuid().ToString()), + ["baseRef"] = Str(" release/2.x "), + }; + + var result = await new AgentCodeNode().RunAsync(BuildContext(RequiredConfig(), resume: null, inputs), CancellationToken.None); + var task = JsonSerializer.Deserialize(result.SuspendUntil!.Payload, AgentJson.Options)!; + + task.Workspace!.Repositories.Single().Ref.ShouldBe("release/2.x"); + } + [Fact] public async Task A_session_base_ref_marks_the_primary_soft_so_a_pruned_branch_falls_back() { diff --git a/frontend/src/components/workflows/AgentCodeInspector.tsx b/frontend/src/components/workflows/AgentCodeInspector.tsx index dba360d99..29541c8ad 100644 --- a/frontend/src/components/workflows/AgentCodeInspector.tsx +++ b/frontend/src/components/workflows/AgentCodeInspector.tsx @@ -174,6 +174,7 @@ export function AgentCodeInspector({ config, inputs, onConfigChange, onInputsCha repositoryId={repositoryId} relatedRepositories={inputs.relatedRepositories} drafts={inputs.workspaceRepoDrafts} + suggestions={suggestions} onChange={(next) => onInputsChange({ ...inputs, repositoryId: next.repositoryId, diff --git a/frontend/src/components/workflows/selectors/RepositoryWorkspacePicker.test.tsx b/frontend/src/components/workflows/selectors/RepositoryWorkspacePicker.test.tsx new file mode 100644 index 000000000..36c1023f3 --- /dev/null +++ b/frontend/src/components/workflows/selectors/RepositoryWorkspacePicker.test.tsx @@ -0,0 +1,39 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +import type { ScopeSuggestion } from "../scope-introspection"; +import { RepositoryWorkspacePicker } from "./RepositoryWorkspacePicker"; + +vi.mock("@/hooks/use-projects", () => ({ useProjects: () => ({ data: [] }) })); +vi.mock("@/hooks/use-repositories", () => ({ useRepositories: () => ({ data: [] }) })); +vi.mock("../VariablePickerInput", () => ({ + VariablePickerInput: ({ value, onChange, placeholder }: { value: string; onChange: (next: string) => void; placeholder?: string }) => ( + onChange(event.target.value)} /> + ), +})); + +const suggestions: ScopeSuggestion[] = [{ path: "trigger.repositoryId", label: "trigger.repositoryId", category: "trigger" }]; + +describe("RepositoryWorkspacePicker expression mode", () => { + it("opens in Expression mode for a repository reference and edits only the primary repository id", () => { + const onChange = vi.fn(); + render(); + + expect(screen.getByRole("button", { name: "Expression" })).toHaveAttribute("data-active", "true"); + const expression = screen.getByRole("textbox", { name: "Type @ to reference an input or step output" }); + fireEvent.change(expression, { target: { value: "{{trigger.otherRepositoryId}}" } }); + + expect(onChange).toHaveBeenCalledWith({ + repositoryId: "{{trigger.otherRepositoryId}}", + relatedRepositories: [{ repositoryId: "related-1", access: "read" }], + workspaceRepoDrafts: undefined, + }); + }); + + it("keeps the repository picker in Pick mode for a literal id", () => { + render(); + + expect(screen.getByRole("button", { name: "Pick" })).toHaveAttribute("data-active", "true"); + expect(screen.getByTestId("workspace-primary-row")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/workflows/selectors/RepositoryWorkspacePicker.tsx b/frontend/src/components/workflows/selectors/RepositoryWorkspacePicker.tsx index e9b976bd6..430472987 100644 --- a/frontend/src/components/workflows/selectors/RepositoryWorkspacePicker.tsx +++ b/frontend/src/components/workflows/selectors/RepositoryWorkspacePicker.tsx @@ -10,6 +10,8 @@ import { type WorkspaceReposEmit, } from "@/lib/nodeRepoWorkspace"; +import type { ScopeSuggestion } from "../scope-introspection"; +import { VariablePickerInput } from "../VariablePickerInput"; import { SearchSelect } from "./SearchSelect"; /** @@ -31,10 +33,11 @@ interface RepositoryWorkspacePickerProps { relatedRepositories: unknown; /** In-progress rows (blank ids) the persisted shape cannot hold — see {@link writeWorkspaceRepos}. */ drafts: unknown; + suggestions?: ScopeSuggestion[]; onChange: (next: WorkspaceReposEmit) => void; } -export function RepositoryWorkspacePicker({ repositoryId, relatedRepositories, drafts, onChange }: RepositoryWorkspacePickerProps) { +export function RepositoryWorkspacePicker({ repositoryId, relatedRepositories, drafts, suggestions, onChange }: RepositoryWorkspacePickerProps) { const rows = useMemo( () => readWorkspaceRepos(repositoryId, relatedRepositories, drafts), [repositoryId, relatedRepositories, drafts], @@ -47,6 +50,12 @@ export function RepositoryWorkspacePicker({ repositoryId, relatedRepositories, d // Per-row project narrowing — UI aid only, never persisted. Keyed by row index; reset on reorder. const [draftProjectByIndex, setDraftProjectByIndex] = useState>(new Map()); + const [mode, setMode] = useState<"pick" | "expr">(() => repositoryId.includes("{{") ? "expr" : "pick"); + const [seenRepositoryId, setSeenRepositoryId] = useState(repositoryId); + if (repositoryId !== seenRepositoryId) { + setSeenRepositoryId(repositoryId); + setMode(repositoryId.includes("{{") ? "expr" : "pick"); + } const projectForRow = (idx: number, row: WorkspaceRepoRow): string => { const draft = draftProjectByIndex.get(idx); @@ -56,6 +65,18 @@ export function RepositoryWorkspacePicker({ repositoryId, relatedRepositories, d }; const emit = (next: WorkspaceRepoRow[]) => onChange(writeWorkspaceRepos(next)); + const emitRepositoryId = (next: string) => { + const related = rows.slice(1).map((row) => ({ + repositoryId: row.repositoryId, + access: row.access, + ...(row.alias.trim() !== "" ? { alias: row.alias.trim() } : {}), + })); + onChange({ + repositoryId: next || undefined, + relatedRepositories: related.length > 0 ? related : undefined, + workspaceRepoDrafts: rows.some((row) => row.repositoryId === "") ? rows : undefined, + }); + }; const addRow = () => emit([...rows, { repositoryId: "", alias: "", access: "read" }]); @@ -94,29 +115,48 @@ export function RepositoryWorkspacePicker({ repositoryId, relatedRepositories, d return (
- {rows.map((row, idx) => ( - pickProjectForRow(idx, projectId)} - onPickRepo={(id) => updateRow(idx, { repositoryId: id })} - onChangeAlias={(alias) => updateRow(idx, { alias })} - onChangeAccess={(access) => updateRow(idx, { access })} - onMakePrimary={() => makePrimary(idx)} - onRemove={() => removeRow(idx)} - /> - ))} + {suggestions && suggestions.length > 0 && ( +
+
+ + +
+ {mode === "expr" && ( + + )} +
+ )} + {rows.map((row, idx) => { + if (idx === 0 && suggestions && suggestions.length > 0 && mode === "expr") return null; + return ( + pickProjectForRow(idx, projectId)} + onPickRepo={(id) => updateRow(idx, { repositoryId: id })} + onChangeAlias={(alias) => updateRow(idx, { alias })} + onChangeAccess={(access) => updateRow(idx, { access })} + onMakePrimary={() => makePrimary(idx)} + onRemove={() => removeRow(idx)} + /> + ); + })} - + } - {rows.length === 0 && ( + {rows.length === 0 && mode === "pick" && (
No repository — an analysis-only run.