feat: projects as backend sources with system prompt injection (#8739)

Signed-off-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Douwe Osinga <douwe@squareup.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Douwe Osinga
2026-05-07 10:30:15 -04:00
committed by GitHub
parent bced4ea5ec
commit ff42171903
29 changed files with 1241 additions and 919 deletions
@@ -15,7 +15,6 @@ import { selectProjects } from "@/features/projects/stores/projectSelectors";
import { resolveAgentProviderCatalogIdStrictFromEntries } from "@/features/providers/providerCatalog";
import { useProviderCatalogStore } from "@/features/providers/stores/providerCatalogStore";
import {
buildProjectSystemPrompt,
composeSystemPrompt,
resolveProjectDefaultArtifactRoot,
} from "@/features/projects/lib/chatProjectContext";
@@ -31,6 +30,7 @@ import {
useResolvedAgentModelPicker,
type PreferredModelSelection,
} from "./useResolvedAgentModelPicker";
import { updateSessionProject } from "@/shared/api/acpApi";
interface UseChatSessionControllerOptions {
sessionId: string | null;
@@ -147,22 +147,14 @@ export function useChatSessionController({
})),
[projects],
);
const projectSystemPrompt = useMemo(
() => buildProjectSystemPrompt(project),
[project],
);
const workingContextPrompt = useMemo(() => {
if (!activeWorkspace?.branch) return undefined;
return `<active-working-context>\nActive branch: ${activeWorkspace.branch}\nWorking directory: ${activeWorkspace.path}\n</active-working-context>`;
}, [activeWorkspace?.branch, activeWorkspace?.path]);
const effectiveSystemPrompt = useMemo(
() =>
composeSystemPrompt(
selectedPersona?.systemPrompt,
projectSystemPrompt,
workingContextPrompt,
),
[projectSystemPrompt, selectedPersona?.systemPrompt, workingContextPrompt],
composeSystemPrompt(selectedPersona?.systemPrompt, workingContextPrompt),
[selectedPersona?.systemPrompt, workingContextPrompt],
);
const prepareCurrentSession = useCallback(
@@ -332,6 +324,9 @@ export function useChatSessionController({
null);
useChatSessionStore.getState().patchSession(sessionId, { projectId });
void updateSessionProject(sessionId, projectId).catch(console.error);
if (!selectedProvider) {
return;
}
@@ -735,6 +730,9 @@ export function useChatSessionController({
}
if (hasPendingProject) {
patch.projectId = nextProjectId ?? null;
void updateSessionProject(sessionId, nextProjectId ?? null).catch(
console.error,
);
}
useChatSessionStore.getState().patchSession(sessionId, patch);
+178 -44
View File
@@ -1,7 +1,10 @@
import { invoke } from "@tauri-apps/api/core";
import { getClient } from "@/shared/api/acpConnection";
export interface ProjectInfo {
id: string;
/** Stable on-disk path of the project source. Pass back to update/delete. */
path: string;
name: string;
description: string;
prompt: string;
@@ -13,8 +16,86 @@ export interface ProjectInfo {
useWorktrees: boolean;
order: number;
archivedAt: string | null;
createdAt: string;
updatedAt: string;
}
// Shape returned by _goose/sources/*. Narrowed to project-type sources here.
interface SourceEntry {
type: "project";
name: string;
description: string;
content: string;
path: string;
global: boolean;
properties: Record<string, unknown>;
}
function toProjectInfo(source: SourceEntry): ProjectInfo {
const p = source.properties ?? {};
return {
id: source.name,
path: source.path,
name: (p.title as string) ?? source.name,
description: source.description,
prompt: source.content,
icon: (p.icon as string) ?? "",
color: (p.color as string) ?? "",
preferredProvider: (p.preferredProvider as string) ?? null,
preferredModel: (p.preferredModel as string) ?? null,
workingDirs: (p.workingDirs as string[]) ?? [],
useWorktrees: (p.useWorktrees as boolean) ?? false,
order: (p.order as number) ?? 0,
archivedAt: (p.archivedAt as string) ?? null,
};
}
interface ProjectMetadataFields {
name: string;
icon: string;
color: string;
preferredProvider: string | null;
preferredModel: string | null;
workingDirs: string[];
useWorktrees: boolean;
order: number;
archivedAt: string | null;
}
function toProperties(info: ProjectMetadataFields): Record<string, unknown> {
const props: Record<string, unknown> = {};
if (info.name) props.title = info.name;
if (info.icon) props.icon = info.icon;
if (info.color) props.color = info.color;
if (info.preferredProvider) props.preferredProvider = info.preferredProvider;
if (info.preferredModel) props.preferredModel = info.preferredModel;
if (info.workingDirs?.length) props.workingDirs = info.workingDirs;
if (info.useWorktrees) props.useWorktrees = info.useWorktrees;
if (typeof info.order === "number") props.order = info.order;
if (info.archivedAt) props.archivedAt = info.archivedAt;
return props;
}
function slugify(name: string): string {
const slug = name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return slug || "project";
}
/** Pick a slug for `name` that does not collide with any existing project ID
* (active or archived). Two display names that normalize to the same slug
* (e.g. "My App" and "my-app", or both collapsing to "project" because they
* contain no ASCII alphanumerics) are disambiguated with a numeric suffix. */
function uniqueProjectSlug(name: string, existingIds: Set<string>): string {
const base = slugify(name);
if (!existingIds.has(base)) {
return base;
}
let counter = 2;
while (existingIds.has(`${base}-${counter}`)) {
counter += 1;
}
return `${base}-${counter}`;
}
export interface ProjectIconCandidate {
@@ -29,7 +110,15 @@ export interface ProjectIconData {
}
export async function listProjects(): Promise<ProjectInfo[]> {
return invoke("list_projects");
const client = await getClient();
const raw = await client.extMethod("_goose/sources/list", {
type: "project",
});
const sources = (raw.sources ?? []) as SourceEntry[];
return sources
.map(toProjectInfo)
.filter((p) => p.archivedAt === null)
.sort((a, b) => a.order - b.order);
}
export async function scanProjectIcons(
@@ -53,67 +142,112 @@ export async function createProject(
workingDirs: string[],
useWorktrees: boolean,
): Promise<ProjectInfo> {
return invoke("create_project", {
name,
const client = await getClient();
const existing = await listAllProjects();
const id = uniqueProjectSlug(name, new Set(existing.map((p) => p.id)));
const raw = await client.extMethod("_goose/sources/create", {
type: "project",
name: id,
description,
prompt,
icon,
color,
preferredProvider,
preferredModel,
workingDirs,
useWorktrees,
content: prompt,
global: true,
properties: toProperties({
name,
icon,
color,
preferredProvider,
preferredModel,
workingDirs,
useWorktrees,
order: 0,
archivedAt: null,
}),
});
return toProjectInfo(raw.source as SourceEntry);
}
export async function updateProject(
id: string,
name: string,
description: string,
prompt: string,
icon: string,
color: string,
preferredProvider: string | null,
preferredModel: string | null,
workingDirs: string[],
useWorktrees: boolean,
existing: ProjectInfo,
updates: Partial<Omit<ProjectInfo, "id" | "path">>,
): Promise<ProjectInfo> {
return invoke("update_project", {
id,
name,
description,
prompt,
icon,
color,
preferredProvider,
preferredModel,
workingDirs,
useWorktrees,
const merged = { ...existing, ...updates };
const client = await getClient();
const raw = await client.extMethod("_goose/sources/update", {
type: "project",
path: existing.path,
name: existing.id,
description: merged.description,
content: merged.prompt,
properties: toProperties({
name: merged.name,
icon: merged.icon,
color: merged.color,
preferredProvider: merged.preferredProvider,
preferredModel: merged.preferredModel,
workingDirs: merged.workingDirs,
useWorktrees: merged.useWorktrees,
order: merged.order,
archivedAt: merged.archivedAt,
}),
});
return toProjectInfo(raw.source as SourceEntry);
}
export async function deleteProject(
idOrProject: string | ProjectInfo,
): Promise<void> {
const client = await getClient();
const path =
typeof idOrProject === "string"
? (await getProject(idOrProject)).path
: idOrProject.path;
await client.extMethod("_goose/sources/delete", {
type: "project",
path,
});
}
export async function deleteProject(id: string): Promise<void> {
return invoke("delete_project", { id });
}
export async function getProject(id: string): Promise<ProjectInfo> {
return invoke("get_project", { id });
const all = await listAllProjects();
const match = all.find((p) => p.id === id);
if (!match) throw new Error(`Project "${id}" not found`);
return match;
}
export async function listArchivedProjects(): Promise<ProjectInfo[]> {
return invoke("list_archived_projects");
/** List both archived and active projects. */
async function listAllProjects(): Promise<ProjectInfo[]> {
const client = await getClient();
const raw = await client.extMethod("_goose/sources/list", {
type: "project",
});
const sources = (raw.sources ?? []) as SourceEntry[];
return sources.map(toProjectInfo);
}
export async function archiveProject(id: string): Promise<void> {
return invoke("archive_project", { id });
const project = await getProject(id);
await updateProject(project, {
archivedAt: new Date().toISOString(),
});
}
export async function restoreProject(id: string): Promise<void> {
const project = await getProject(id);
await updateProject(project, { archivedAt: null });
}
export async function reorderProjects(
order: [string, number][],
): Promise<void> {
return invoke("reorder_projects", { order });
const all = await listAllProjects();
for (const [id, orderValue] of order) {
const existing = all.find((p) => p.id === id);
if (!existing) continue;
await updateProject(existing, { order: orderValue });
}
}
export async function restoreProject(id: string): Promise<void> {
return invoke("restore_project", { id });
export async function listArchivedProjects(): Promise<ProjectInfo[]> {
const all = await listAllProjects();
return all.filter((p) => p.archivedAt !== null);
}
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import {
buildProjectSystemPrompt,
composeSystemPrompt,
getProjectArtifactRoots,
getProjectFolderName,
@@ -8,44 +7,6 @@ import {
} from "./chatProjectContext";
describe("chatProjectContext", () => {
it("builds project instructions from stored project settings", () => {
const systemPrompt = buildProjectSystemPrompt({
id: "project-1",
name: "Goose2",
description: "Desktop app",
prompt: "Always read AGENTS.md before editing.",
icon: "folder",
color: "#000000",
preferredProvider: "goose",
preferredModel: "claude-sonnet-4",
workingDirs: ["/Users/wesb/dev/goose2"],
useWorktrees: true,
order: 0,
archivedAt: null,
createdAt: "now",
updatedAt: "now",
});
expect(systemPrompt).toContain("<project-settings>");
expect(systemPrompt).toContain("Project name: Goose2");
expect(systemPrompt).toContain(
"Working directories: /Users/wesb/dev/goose2",
);
expect(systemPrompt).toContain(
"Default working directory: /Users/wesb/dev/goose2",
);
expect(systemPrompt).toContain("Preferred provider: goose");
expect(systemPrompt).toContain(
"Use git worktrees for branch isolation: yes",
);
expect(systemPrompt).toContain("<project-file-policy>");
expect(systemPrompt).toContain(
"Use /Users/wesb/dev/goose2 as the default working directory for this project.",
);
expect(systemPrompt).toContain("<project-instructions>");
expect(systemPrompt).toContain("Always read AGENTS.md before editing.");
});
it("combines persona and project prompts without empty sections", () => {
expect(
composeSystemPrompt("Persona prompt", undefined, "Project prompt"),
@@ -1,5 +1,6 @@
import type { ProjectInfo } from "../api/projects";
import { resolvePath } from "@/shared/api/pathResolver";
export interface ProjectFolderOption {
id: string;
name: string;
@@ -36,7 +37,7 @@ export function getProjectArtifactRoots(
}
export function resolveProjectDefaultArtifactRoot(
project: ProjectInfo | null | undefined,
project: Pick<ProjectInfo, "workingDirs"> | null | undefined,
): string | undefined {
const workingDirs = resolveProjectRoots(project);
return workingDirs[0];
@@ -56,61 +57,6 @@ export function getProjectFolderOption(
}));
}
export function buildProjectSystemPrompt(
project: ProjectInfo | null | undefined,
): string | undefined {
if (!project) {
return undefined;
}
const workingDir = resolveProjectDefaultArtifactRoot(project);
const settings: string[] = [`Project name: ${project.name}`];
const description = trimValue(project.description);
const workingDirs = resolveProjectRoots(project);
const prompt = trimValue(project.prompt);
if (description) {
settings.push(`Project description: ${description}`);
}
if (workingDirs.length > 0) {
settings.push(`Working directories: ${workingDirs.join(", ")}`);
}
if (workingDir) {
settings.push(`Default working directory: ${workingDir}`);
}
if (project.preferredProvider) {
settings.push(`Preferred provider: ${project.preferredProvider}`);
}
if (project.preferredModel) {
settings.push(`Preferred model: ${project.preferredModel}`);
}
settings.push(
`Use git worktrees for branch isolation: ${
project.useWorktrees ? "yes" : "no"
}`,
);
const sections = [
`<project-settings>\n${settings.join("\n")}\n</project-settings>`,
];
if (workingDir) {
sections.push(
`<project-file-policy>\n` +
`Use ${workingDir} as the default working directory for this project.\n` +
`Write newly generated files relative to ${workingDir} by default.\n` +
`Only write outside ${workingDir} when the user explicitly asks you to edit or create a file at a specific path.\n` +
`</project-file-policy>`,
);
}
if (prompt) {
sections.push(`<project-instructions>\n${prompt}\n</project-instructions>`);
}
return sections.join("\n\n");
}
export function composeSystemPrompt(
...parts: Array<string | null | undefined>
): string | undefined {
@@ -14,6 +14,7 @@ vi.mock("@/shared/api/pathResolver", () => ({
function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
return {
id: "project-1",
path: "/tmp/projects/project-1.md",
name: "Project",
description: "",
prompt: "",
@@ -25,8 +26,6 @@ function makeProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
useWorktrees: false,
order: 0,
archivedAt: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
@@ -125,8 +125,9 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
workingDirs,
useWorktrees,
) => {
const project = await updateProject(
id,
const existing = get().projects.find((p) => p.id === id);
if (!existing) throw new Error(`Project ${id} not found`);
const project = await updateProject(existing, {
name,
description,
prompt,
@@ -136,7 +137,7 @@ export const useProjectStore = create<ProjectStore>((set, get) => ({
preferredModel,
workingDirs,
useWorktrees,
);
});
set((state) => ({
projects: state.projects.map((p) => (p.id === id ? project : p)),
}));
@@ -188,18 +188,17 @@ export function CreateProjectDialog({
try {
let savedProject: ProjectInfo;
if (isEditing) {
savedProject = await updateProject(
editingProject.id,
name.trim(),
"",
parsedPrompt,
savedProject = await updateProject(editingProject, {
name: name.trim(),
description: "",
prompt: parsedPrompt,
icon,
color,
preferredProvider || null,
preferredProvider: preferredProvider || null,
preferredModel,
workingDirs,
useWorktrees,
);
});
} else {
savedProject = await createProject(
name.trim(),
@@ -28,6 +28,7 @@ vi.mock("@/shared/api/system", () => ({
vi.mock("../../api/projects", () => ({
createProject: vi.fn().mockResolvedValue({
id: "new-1",
path: "/tmp/projects/new-1.md",
name: "Test",
description: "",
prompt: "",
@@ -39,11 +40,10 @@ vi.mock("../../api/projects", () => ({
useWorktrees: false,
order: 0,
archivedAt: null,
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
}),
updateProject: vi.fn().mockResolvedValue({
id: "proj-1",
path: "/tmp/projects/proj-1.md",
name: "Updated",
description: "",
prompt: "",
@@ -55,8 +55,6 @@ vi.mock("../../api/projects", () => ({
useWorktrees: false,
order: 0,
archivedAt: null,
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
}),
scanProjectIcons: vi.fn().mockResolvedValue([]),
readProjectIcon: vi.fn().mockResolvedValue({
@@ -93,6 +91,7 @@ vi.mock("../PromptEditor", () => ({
function makeEditingProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
return {
id: "proj-1",
path: "/tmp/projects/proj-1.md",
name: "My Project",
description: "A test project",
prompt: "Do the thing",
@@ -104,8 +103,6 @@ function makeEditingProject(overrides: Partial<ProjectInfo> = {}): ProjectInfo {
useWorktrees: false,
order: 0,
archivedAt: null,
createdAt: "2024-01-01",
updatedAt: "2024-01-01",
...overrides,
};
}
@@ -25,7 +25,7 @@ describe("listSkills", () => {
name: "code-review",
description: "Reviews code",
content: "Review carefully",
directory: "/Users/test/.agents/skills/code-review",
path: "/Users/test/.agents/skills/code-review",
global: true,
},
],
@@ -38,7 +38,7 @@ describe("listSkills", () => {
name: "code-review",
description: "Reviews code",
content: "Review carefully",
directory: "/Users/test/.agents/skills/code-review",
path: "/Users/test/.agents/skills/code-review",
global: true,
},
{
@@ -46,7 +46,7 @@ describe("listSkills", () => {
name: "test-writer",
description: "Writes tests",
content: "Write tests",
directory: "/tmp/alpha/.agents/skills/test-writer",
path: "/tmp/alpha/.agents/skills/test-writer",
global: false,
},
],
@@ -97,7 +97,7 @@ describe("listSkills", () => {
name: "legacy-writer",
description: "Legacy project skill",
content: "Legacy instructions",
directory: "/tmp/beta/.goose/skills/legacy-writer",
path: "/tmp/beta/.goose/skills/legacy-writer",
global: false,
},
],
@@ -133,7 +133,7 @@ describe("listSkills", () => {
name: "code-review",
description: "Reviews code",
content: "Review carefully",
directory: "/Users/test/.agents/skills/code-review",
path: "/Users/test/.agents/skills/code-review",
global: true,
},
],
@@ -147,7 +147,7 @@ describe("listSkills", () => {
name: "test-writer",
description: "Writes tests",
content: "Write tests",
directory: "/tmp/beta/.agents/skills/test-writer",
path: "/tmp/beta/.agents/skills/test-writer",
global: false,
},
],
@@ -172,7 +172,7 @@ describe("listSkills", () => {
name: "code-review",
description: "Reviews code",
content: "Review carefully",
directory: "/Users/test/.agents/skills/code-review",
path: "/Users/test/.agents/skills/code-review",
global: true,
},
],
@@ -185,7 +185,7 @@ describe("listSkills", () => {
name: "test-writer",
description: "Writes tests",
content: "Write tests",
directory: "/tmp/alpha/.agents/skills/test-writer",
path: "/tmp/alpha/.agents/skills/test-writer",
global: false,
},
],
@@ -219,7 +219,7 @@ describe("listSkills", () => {
name: "personal-review",
description: "Reviews personal code",
content: "Review local changes",
directory: "/Users/test/.agents/skills/personal-review",
path: "/Users/test/.agents/skills/personal-review",
global: true,
},
],
@@ -231,7 +231,7 @@ describe("listSkills", () => {
name: "goose-doc-guide",
description: "Goose documentation guide",
content: "Use Goose docs",
directory: "builtin://skills/goose-doc-guide",
path: "builtin://skills/goose-doc-guide",
global: true,
},
],
@@ -243,7 +243,7 @@ describe("listSkills", () => {
name: "project-helper",
description: "Helps project work",
content: "Use project context",
directory: "/tmp/alpha/.agents/skills/project-helper",
path: "/tmp/alpha/.agents/skills/project-helper",
global: false,
},
],
+28 -9
View File
@@ -62,8 +62,8 @@ function toSkillInfo(source: SkillSourceEntry): SkillInfo {
name: source.name,
description: source.description,
instructions: source.content,
path: source.directory,
fileLocation: source.directory,
path: source.path,
fileLocation: source.path,
sourceKind: "builtin",
sourceLabel: "Built in",
projectLinks: [],
@@ -71,10 +71,21 @@ function toSkillInfo(source: SkillSourceEntry): SkillInfo {
}
const sourceKind: SkillSourceKind = source.global ? "global" : "project";
const props = (source.properties ?? {}) as Record<string, unknown>;
// Backend tags project-scoped skills with these when listing via
// include_project_sources. Prefer them over path-derived values so badges
// show the user-visible project title.
const taggedProjectDir =
typeof props.projectDir === "string" ? props.projectDir : null;
const taggedProjectName =
typeof props.projectName === "string" ? props.projectName : null;
const projectRoot = source.global
? null
: deriveProjectRoot(source.directory);
const projectName = projectRoot ? basename(projectRoot) : "";
: (taggedProjectDir ?? deriveProjectRoot(source.path));
const projectName =
taggedProjectName ?? (projectRoot ? basename(projectRoot) : "");
const projectLinks: SkillProjectLink[] = projectRoot
? [
@@ -87,12 +98,12 @@ function toSkillInfo(source: SkillSourceEntry): SkillInfo {
: [];
return {
id: `${sourceKind}:${source.directory}`,
id: `${sourceKind}:${source.path}`,
name: source.name,
description: source.description,
instructions: source.content,
path: source.directory,
fileLocation: getSkillFileLocation(source.directory),
path: source.path,
fileLocation: getSkillFileLocation(source.path),
sourceKind,
sourceLabel:
sourceKind === "global" ? "Personal" : projectName || "Project",
@@ -104,10 +115,17 @@ function uniqueProjectDirs(projectDirs: string[]) {
return [...new Set(projectDirs.map((dir) => dir.trim()).filter(Boolean))];
}
export interface CreateSkillOptions {
/** Project source ID (kebab slug). When set, the skill is created under
* that project's first working directory. */
projectId?: string;
}
export async function createSkill(
name: string,
description: string,
instructions: string,
options: CreateSkillOptions = {},
): Promise<void> {
const client = await getClient();
await client.goose.GooseSourcesCreate({
@@ -115,7 +133,8 @@ export async function createSkill(
name,
description,
content: instructions,
global: true,
global: !options.projectId,
...(options.projectId ? { projectId: options.projectId } : {}),
});
}
@@ -164,7 +183,7 @@ export async function listSkills(
const key =
source.type === BUILTIN_SKILL_SOURCE_TYPE
? `builtin:${source.name}`
: `${source.global ? "global" : "project"}:${source.directory}`;
: `${source.global ? "global" : "project"}:${source.path}`;
if (seen.has(key)) {
continue;
}
@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/shared/ui/button";
import { Input } from "@/shared/ui/input";
@@ -11,6 +11,14 @@ import {
DialogHeader,
DialogTitle,
} from "@/shared/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { useProjectStore } from "@/features/projects/stores/projectStore";
import {
createSkill,
updateSkill,
@@ -20,6 +28,9 @@ import {
import { formatSkillName, isValidSkillName } from "../lib/skillsHelpers";
import { getRenamedSkillFileLocation } from "../lib/skillsPath";
/** Sentinel value for the "Global" option in the save-location picker. */
const GLOBAL_VALUE = "__global__";
interface SkillEditorProps {
isOpen: boolean;
onClose: () => void;
@@ -37,9 +48,18 @@ export function SkillEditor({
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [instructions, setInstructions] = useState("");
const [saveLocation, setSaveLocation] = useState(GLOBAL_VALUE);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const projects = useProjectStore((s) => s.projects);
// Only projects with working directories can hold skills
const projectsWithDirs = useMemo(
() => projects.filter((p) => p.workingDirs.length > 0),
[projects],
);
const isEditing = !!editingSkill;
// Pre-fill fields when editing
@@ -48,11 +68,13 @@ export function SkillEditor({
setName(editingSkill.name);
setDescription(editingSkill.description);
setInstructions(editingSkill.instructions);
setSaveLocation(GLOBAL_VALUE); // location is fixed for existing skills
setError(null);
} else if (isOpen) {
setName("");
setDescription("");
setInstructions("");
setSaveLocation(GLOBAL_VALUE);
setError(null);
}
}, [isOpen, editingSkill]);
@@ -69,6 +91,7 @@ export function SkillEditor({
setName("");
setDescription("");
setInstructions("");
setSaveLocation(GLOBAL_VALUE);
setError(null);
onClose();
};
@@ -88,11 +111,16 @@ export function SkillEditor({
instructions,
);
} else {
await createSkill(name, description.trim(), instructions);
const projectId =
saveLocation !== GLOBAL_VALUE ? saveLocation : undefined;
await createSkill(name, description.trim(), instructions, {
projectId,
});
}
setName("");
setDescription("");
setInstructions("");
setSaveLocation(GLOBAL_VALUE);
await onSaved?.(savedSkill);
onClose();
} catch (err) {
@@ -133,6 +161,35 @@ export function SkillEditor({
)}
</div>
{/* Save location — only shown when creating */}
{!isEditing && projectsWithDirs.length > 0 && (
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
{t("dialog.saveLocation")}
</Label>
<Select value={saveLocation} onValueChange={setSaveLocation}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={GLOBAL_VALUE}>
{t("dialog.global")}
</SelectItem>
{projectsWithDirs.map((project) => (
<SelectItem key={project.id} value={project.id}>
{project.name}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-muted-foreground">
{saveLocation === GLOBAL_VALUE
? t("dialog.globalHint")
: t("dialog.projectHint")}
</p>
</div>
)}
{/* Description */}
<div className="space-y-1">
<Label className="text-xs font-medium text-muted-foreground">
@@ -244,6 +244,7 @@ describe("SkillEditor", () => {
"my-skill",
"A description",
"Some instructions",
{ projectId: undefined },
);
});
@@ -5,6 +5,8 @@
"description": "Description",
"descriptionPlaceholder": "What it does and when to use it...",
"editTitle": "Edit skill",
"global": "Global",
"globalHint": "Available to all sessions",
"instructions": "Instructions",
"instructionsPlaceholder": "Markdown instructions the agent will follow...",
"name": "Name",
@@ -12,6 +14,8 @@
"nameValidation": "Use 164 lowercase letters, numbers, or hyphens. Names cannot start or end with a hyphen.",
"pathOnDisk": "Path on disk",
"newTitle": "New skill",
"projectHint": "Stored in the project folder",
"saveLocation": "Save to",
"saving": "Saving..."
},
"view": {