Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,12 @@ copies to `src-tauri/binaries/goosed-$(rustc -vV | sed -n 's|host: ||p')`, which
is the filename Tauri expects. Use `GOOSE_BIN=/path/to/goose` only as an explicit
local override.

## Projects

`ProjectInfo.id` is assigned when a project is created and must remain
stable. Renaming a project updates only its display name; sessions, chat
groups, and other persisted references continue to use the original id.

## Conventions

- Use `@/` imports for frontend code.
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/crates/berdctl/api-surface-feedback.json
Original file line number Diff line number Diff line change
Expand Up @@ -924,7 +924,7 @@
"description": "Manage the user's projects: create, list, get, attach folder, detach folder, set startup mode, archive.",
"actions": {
"create": {
"description": "Create a new project; it appears immediately in the app's project list.",
"description": "Create a new project; it appears immediately in the app's project list. The result includes a warning if a working directory is already attached to another active project.",
"fields": [
{
"name": "name",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/crates/berdctl/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -924,7 +924,7 @@
"description": "Manage the user's projects: create, list, get, attach folder, detach folder, set startup mode, archive.",
"actions": {
"create": {
"description": "Create a new project; it appears immediately in the app's project list.",
"description": "Create a new project; it appears immediately in the app's project list. The result includes a warning if a working directory is already attached to another active project.",
"fields": [
{
"name": "name",
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/crates/berdctl/cli-surface-feedback.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
"create": {
"action": "create",
"about": "Create a new project",
"afterHelp": "Example:\n berdctl project create --name \"Code reviews\" \\\n --instructions \"Prefer small diffs\" \\\n --working-dir /Users/me/src/api --working-dir /Users/me/src/web\n\nResult:\n {\"project_id\": \"...\"} — the project appears immediately in the app's\n project list with both directories attached."
"afterHelp": "Example:\n berdctl project create --name \"Code reviews\" \\\n --instructions \"Prefer small diffs\" \\\n --working-dir /Users/me/src/api --working-dir /Users/me/src/web\n\nResult:\n {\"project_id\": \"...\", \"warning\": \"...\"?} — the project appears\n immediately in the app's project list with both directories attached."
},
"list": {
"action": "list",
Expand Down Expand Up @@ -219,7 +219,7 @@
"context": {
"action": "get_context",
"about": "Read what the user is looking at in the app right now",
"afterHelp": "Example:\n berdctl info context --json\n\nResult:\n {\"view\": \"...\", \"active_session_id\": \"...\"|null,\n \"active_project_id\": \"...\"|null, \"voice_session_active\": true|false,\n \"app_version\": \"...\"}"
"afterHelp": "Example:\n berdctl info context --json\n\nResult:\n {\"view\": \"...\", \"active_session_id\": \"...\"|null,\n \"active_project_id\": \"...\"|null, \"active_project_name\": \"...\"|null,\n \"voice_session_active\": true|false, \"app_version\": \"...\"}\n\n\"active_project_id\" is a stable identifier that does not change when the\nproject is renamed; \"active_project_name\" is the project's current display\nname and may not match the id (e.g. \"goose-internal\" / \"Berd\")."
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src-tauri/crates/berdctl/cli-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
"create": {
"action": "create",
"about": "Create a new project",
"afterHelp": "Example:\n berdctl project create --name \"Code reviews\" \\\n --instructions \"Prefer small diffs\" \\\n --working-dir /Users/me/src/api --working-dir /Users/me/src/web\n\nResult:\n {\"project_id\": \"...\"} — the project appears immediately in the app's\n project list with both directories attached."
"afterHelp": "Example:\n berdctl project create --name \"Code reviews\" \\\n --instructions \"Prefer small diffs\" \\\n --working-dir /Users/me/src/api --working-dir /Users/me/src/web\n\nResult:\n {\"project_id\": \"...\", \"warning\": \"...\"?} — the project appears\n immediately in the app's project list with both directories attached."
},
"list": {
"action": "list",
Expand Down Expand Up @@ -203,7 +203,7 @@
"context": {
"action": "get_context",
"about": "Read what the user is looking at in the app right now",
"afterHelp": "Example:\n berdctl info context --json\n\nResult:\n {\"view\": \"...\", \"active_session_id\": \"...\"|null,\n \"active_project_id\": \"...\"|null, \"voice_session_active\": true|false,\n \"app_version\": \"...\"}"
"afterHelp": "Example:\n berdctl info context --json\n\nResult:\n {\"view\": \"...\", \"active_session_id\": \"...\"|null,\n \"active_project_id\": \"...\"|null, \"active_project_name\": \"...\"|null,\n \"voice_session_active\": true|false, \"app_version\": \"...\"}\n\n\"active_project_id\" is a stable identifier that does not change when the\nproject is renamed; \"active_project_name\" is the project's current display\nname and may not match the id (e.g. \"goose-internal\" / \"Berd\")."
}
}
}
Expand Down
61 changes: 61 additions & 0 deletions src/features/berdctl/__tests__/commands/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ vi.mock("@/features/projects/api/projects", async (importOriginal) => {
isWorktreeStartupMode: actual.isWorktreeStartupMode,
normalizeProjectWorkspaces: actual.normalizeProjectWorkspaces,
projectWorkspaceFromDirectory: actual.projectWorkspaceFromDirectory,
findProjectByWorkingDirectory: actual.findProjectByWorkingDirectory,
listProjects: (...args: unknown[]) => mocks.listProjects(...args),
createProject: (...args: unknown[]) => mocks.createProject(...args),
archiveProject: (...args: unknown[]) => mocks.archiveProject(...args),
Expand Down Expand Up @@ -3851,6 +3852,31 @@ describe("projects", () => {
expect(result).toEqual({ project_id: "p-new" });
});

it("create warns (but still creates) when a working dir duplicates an existing active project", async () => {
const existing = makeProject({
id: "existing",
name: "Existing Project",
workingDirs: ["/work"],
});
mocks.listProjects.mockResolvedValue([existing]);
mocks.createProject.mockResolvedValue(makeProject({ id: "p-new" }));

const result = await dispatchCommand(
"projects",
{
action: "create",
name: "Duplicate Project",
working_dir: ["/work"],
},
ctx,
);

expect(result).toEqual({
project_id: "p-new",
warning: expect.stringContaining('"Existing Project" (existing)'),
});
});

it("list refetches from the backend and excludes archived projects", async () => {
// Stale cache that the refetch must replace.
useProjectStore.setState({ projects: [makeProject({ id: "stale" })] });
Expand Down Expand Up @@ -5182,6 +5208,41 @@ describe("info", () => {
);
});

it("get_context pairs the stable active project id with its current display name", async () => {
controller.getAppContext.mockReturnValue({
view: "chat",
activeSessionId: "session-2",
activeProjectId: "project-9",
});
useProjectStore.setState({
projects: [
makeProject({ id: "project-9", name: "Renamed Display Name" }),
],
hasFetchedProjects: true,
});
mocks.getVoiceConversationStatus.mockResolvedValue({
available: true,
unavailableReason: null,
lifecycle: "stopped",
sessionId: null,
ownerWindowLabel: null,
microphoneMuted: false,
revision: 0,
});

const result = (await dispatchCommand(
"info",
{ action: "get_context" },
ctx,
)) as {
active_project_id: string | null;
active_project_name: string | null;
};

expect(result.active_project_id).toBe("project-9");
expect(result.active_project_name).toBe("Renamed Display Name");
});

it("get_context reports app and active voice context", async () => {
controller.getAppContext.mockReturnValue({
view: "chat",
Expand Down
36 changes: 31 additions & 5 deletions src/features/berdctl/commands/impl/createProject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,45 @@ export const createProjectCommand = defineCommand({
destructive: false,
summary: "Create a new project",
description:
"Create a new project; it appears immediately in the app's project list.",
"Create a new project; it appears immediately in the app's project list. " +
"The result includes a warning if a working directory is already " +
"attached to another active project.",
helpFooter: `Example:
berdctl project create --name "Code reviews" \\
--instructions "Prefer small diffs" \\
--working-dir /Users/me/src/api --working-dir /Users/me/src/web

Result:
{"project_id": "..."} — the project appears immediately in the app's
project list with both directories attached.`,
{"project_id": "...", "warning": "..."?} — the project appears
immediately in the app's project list with both directories attached.`,
schema: createProjectSchema,
execute: async (args) => {
const [
{ DEFAULT_PROJECT_COLOR },
{ DEFAULT_PROJECT_ICON },
{ useProjectStore },
{ findProjectByWorkingDirectory },
{ loadProjectsForBerdctl },
] = await Promise.all([
import("@/features/projects/lib/projectDefaults"),
import("@/features/projects/lib/projectIcons"),
import("@/features/projects/stores/projectStore"),
import("@/features/projects/api/projects"),
import("../runtime/projects"),
]);
const workingDirs = args.working_dir ?? [];
let duplicate: { id: string; name: string } | null = null;
if (workingDirs.length > 0) {
await loadProjectsForBerdctl();
const existingProjects = useProjectStore.getState().projects;
for (const dir of workingDirs) {
const match = findProjectByWorkingDirectory(existingProjects, dir);
if (match) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P2 · Report every duplicate directory (non-blocking)

The command accepts multiple working_dir values but stops scanning after the first matching project. The returned warning therefore omits any additional directories already attached to other active projects, even though each omitted conflict carries the same risk the warning is intended to expose.

User effect: An agent can believe it has seen the full conflict and continue with a project that still splits related chats and history across other existing projects.

Recommended fix: Collect every conflicting directory and project before creation and return all conflicts, preferably in a structured result that agents can inspect reliably.

Test: Add a command test with multiple requested directories that conflict with different active projects and assert that every conflict is returned.

duplicate = match;
break;
}
}
}
// Deliberately no berd_project Create Completed telemetry: berdctl
// creates are agent/automation-driven, and the event tracks human-driven
// UI surfaces only — matching the documented berdctl exclusions in the
Expand All @@ -57,9 +76,16 @@ Result:
args.instructions ?? "",
DEFAULT_PROJECT_ICON,
DEFAULT_PROJECT_COLOR,
args.working_dir ?? [],
workingDirs,
false,
);
return { project_id: project.id };
return {
project_id: project.id,
...(duplicate
? {
warning: `A working directory is already attached to project "${duplicate.name}" (${duplicate.id}); the new project was created anyway.`,
}
: {}),
};
},
});
19 changes: 17 additions & 2 deletions src/features/berdctl/commands/impl/getContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ interface GetContextResult {
view: string;
active_session_id: string | null;
active_project_id: string | null;
/** Current display name of the active project, or null. */
active_project_name: string | null;
voice_session_active: boolean;
app_version: string;
}
Expand Down Expand Up @@ -41,29 +43,42 @@ export const getContextCommand = defineCommand({

Result:
{"view": "...", "active_session_id": "..."|null,
"active_project_id": "..."|null, "voice_session_active": true|false,
"app_version": "..."}`,
"active_project_id": "..."|null, "active_project_name": "..."|null,
"voice_session_active": true|false, "app_version": "..."}

"active_project_id" is a stable identifier that does not change when the
project is renamed; "active_project_name" is the project's current display
name and may not match the id (e.g. "goose-internal" / "Berd").`,
schema: getContextSchema,
execute: async (): Promise<GetContextResult> => {
const [
{ default: packageJson },
{ getAppNavigationController },
{ getVoiceConversationStatus },
{ useVoiceConversationStore },
{ useProjectStore },
] = await Promise.all([
import("../../../../../package.json"),
import("../../navigation"),
import("@/features/voice-conversation/api/voiceConversation"),
import("@/features/voice-conversation/stores/voiceConversationStore"),
import("@/features/projects/stores/projectStore"),
]);
const context = getAppNavigationController().getAppContext();
const voiceBeforeRefresh = useVoiceConversationStore.getState();
const nativeVoiceStatus = await getVoiceConversationStatus();
const voiceAfterRefresh = useVoiceConversationStore.getState();
const activeProjectName = context.activeProjectId

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 P1 · Resolve the current project name (blocking)

The new field is derived only from useProjectStore.getState().projects. That store starts from an optional localStorage seed and may be empty before projects load or stale after another writer renames the project, so a valid active project ID can be paired with null or an old display name. This defeats the field's stated purpose of disambiguating stable IDs after rename.

User effect: Agents can still mistake the active project for a different project, or use an outdated project name, even though the command now promises the current display name.

Recommended fix: Resolve the active project through the existing current-data project loader before reading its name, while preserving the captured active project ID for a consistent response; return null only when there is no active project or the current project genuinely cannot be found.

Test: Add discriminating command tests where the store is initially empty and where it contains a stale name while the backend returns the renamed project; assert that the result uses the backend's current name and keeps the same stable ID.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Addressed in c75a215. info context now captures the active project ID before the async read, then resolves its name directly from one current backend snapshot containing active and archived projects. It returns null only when there is no active ID or that ID is genuinely absent from current data; it no longer relies on the renderer cache.

Added command-level regressions for stale cached names, an empty initial store, archived active projects, and no active project. These assert the stable ID is preserved and the returned name is current.

? (useProjectStore
.getState()
.projects.find((project) => project.id === context.activeProjectId)
?.name ?? null)
: null;
return {
view: context.view,
active_session_id: context.activeSessionId,
active_project_id: context.activeProjectId,
active_project_name: activeProjectName,
voice_session_active:
nativeVoiceStatus.sessionId !== null ||
rendererVoiceSessionActive(
Expand Down
28 changes: 28 additions & 0 deletions src/features/projects/api/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,8 @@ describe("projects API artifact metadata", () => {
});

const updateRequest = mocks.sourcesUpdate.mock.calls[0]?.[0];
// The source name (the project's stable id) must not change on rename.
expect(updateRequest.name).toBe("launch");
expect(updateRequest.properties.artifact).toEqual(
createProjectArtifactMetadata({
projectId: "launch",
Expand Down Expand Up @@ -343,6 +345,32 @@ describe("projects API artifact metadata", () => {
});
});

describe("findProjectByWorkingDirectory", () => {
it("finds an active project via a path-equivalent working directory", async () => {
const { findProjectByWorkingDirectory } = await import("./projects");
const existing = projectInfo({
id: "existing",
workingDirs: ["/tmp/launch/"],
});

expect(findProjectByWorkingDirectory([existing], "/tmp/launch")).toEqual(
existing,
);
expect(findProjectByWorkingDirectory([existing], "/tmp/other")).toBeNull();
});

it("ignores archived projects", async () => {
const { findProjectByWorkingDirectory } = await import("./projects");
const archived = projectInfo({
id: "archived",
workingDirs: ["/tmp/launch"],
archivedAt: "2026-01-01T00:00:00.000Z",
});

expect(findProjectByWorkingDirectory([archived], "/tmp/launch")).toBeNull();
});
});

describe("normalizeProjectWorkspaces dedupe identity", () => {
it("collapses Windows drive-equivalent working dirs to one workspace and ID", async () => {
const { normalizeProjectWorkspaces } = await import("./projects");
Expand Down
27 changes: 26 additions & 1 deletion src/features/projects/api/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,10 @@ function slugify(name: string): string {
/** 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. */
* contain no ASCII alphanumerics) are disambiguated with a numeric suffix.
*
* This becomes `ProjectInfo.id` and is computed once, at creation; renaming
* a project must never recompute it (see AGENTS.md). */
function uniqueProjectSlug(name: string, existingIds: Set<string>): string {
const base = slugify(name);
if (!existingIds.has(base)) {
Expand Down Expand Up @@ -390,6 +393,28 @@ export async function listProjects(): Promise<ProjectInfo[]> {
.sort((a, b) => a.order - b.order);
}

/** Returns the first active project that already has `workingDir` among its
* working directories, or null. Does not block creation of a duplicate;
* callers use this to warn instead. */
export function findProjectByWorkingDirectory(
projects: ProjectInfo[],
workingDir: string,
): ProjectInfo | null {
const normalized = normalizeWorkspacePath(workingDir);
if (!normalized) return null;
const key = toIdentityKey(normalized);
for (const project of projects) {
if (project.archivedAt !== null) continue;
for (const dir of project.workingDirs) {
const candidate = normalizeWorkspacePath(dir);
if (candidate && toIdentityKey(candidate) === key) {
return project;
}
}
}
return null;
}

export async function scanProjectIcons(
workingDirs: string[],
): Promise<ProjectIconCandidate[]> {
Expand Down
Loading