Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -661,7 +661,7 @@ private static bool TryReadModelCredentialModelId(NodeRunContext context, out Gu
/// <summary>Read the optional <c>baseRef</c> input — the branch/ref to clone the primary repo at (session branch continuity). Absent / blank / non-string → null (the repo default).</summary>
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;

/// <summary>Read the optional <c>baseRefFromSession</c> input — true ONLY when the launch projection set <c>baseRef</c> 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.</summary>
Expand Down
15 changes: 15 additions & 0 deletions backend/tests/CodeSpace.UnitTests/Workflows/AgentCodeNodeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, JsonElement>
{
["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<AgentTask>(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()
{
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/workflows/AgentCodeInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 }) => (
<input aria-label={placeholder} value={value} onChange={(event) => 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(<RepositoryWorkspacePicker repositoryId="{{trigger.repositoryId}}" relatedRepositories={[{ repositoryId: "related-1" }]} drafts={undefined} suggestions={suggestions} onChange={onChange} />);

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(<RepositoryWorkspacePicker repositoryId="repo-1" relatedRepositories={undefined} drafts={undefined} suggestions={suggestions} onChange={vi.fn()} />);

expect(screen.getByRole("button", { name: "Pick" })).toHaveAttribute("data-active", "true");
expect(screen.getByTestId("workspace-primary-row")).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
type WorkspaceReposEmit,
} from "@/lib/nodeRepoWorkspace";

import type { ScopeSuggestion } from "../scope-introspection";
import { VariablePickerInput } from "../VariablePickerInput";
import { SearchSelect } from "./SearchSelect";

/**
Expand All @@ -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],
Expand All @@ -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<Map<number, string>>(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);
Expand All @@ -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" }]);

Expand Down Expand Up @@ -94,29 +115,48 @@ export function RepositoryWorkspacePicker({ repositoryId, relatedRepositories, d

return (
<div className="wf-relrepo" data-testid="repository-workspace-picker">
{rows.map((row, idx) => (
<RepoRow
key={idx}
row={row}
isPrimary={idx === 0}
projectId={projectForRow(idx, row)}
projects={projectRows}
repositories={repoRows}
onPickProject={(projectId) => 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 && (
<div className="wf-dualmode">
<div className="wf-dualmode-head" role="group" aria-label="Value mode">
<button type="button" className="wf-dualmode-toggle" data-active={mode === "pick"} onClick={() => setMode("pick")}>Pick</button>
<button type="button" className="wf-dualmode-toggle" data-active={mode === "expr"} onClick={() => setMode("expr")}>Expression</button>
</div>
{mode === "expr" && (
<VariablePickerInput
value={repositoryId}
onChange={emitRepositoryId}
suggestions={suggestions}
placeholder="Type @ to reference an input or step output"
/>
)}
</div>
)}
{rows.map((row, idx) => {
if (idx === 0 && suggestions && suggestions.length > 0 && mode === "expr") return null;
return (
<RepoRow
key={idx}
row={row}
isPrimary={idx === 0}
projectId={projectForRow(idx, row)}
projects={projectRows}
repositories={repoRows}
onPickProject={(projectId) => 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)}
/>
);
})}

<button type="button" className="wf-relrepo-add" onClick={addRow}>
{(mode === "pick" || !suggestions || suggestions.length === 0) && <button type="button" className="wf-relrepo-add" onClick={addRow}>
<Ic.Plus size={11} />
<span>{rows.length === 0 ? "Add a repository" : "Add another repository"}</span>
</button>
</button>}

{rows.length === 0 && (
{rows.length === 0 && mode === "pick" && (
<div className="wf-relrepo-hint">
<span aria-hidden="true">ⓘ</span>
<span>No repository — an analysis-only run.</span>
Expand Down
Loading