Skip to content
Open
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
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\": \"...\"?, \"duplicate_working_dirs\": [\n {\"working_dir\": \"...\", \"project_id\": \"...\", \"project_name\": \"...\"}\n ]?} — the project appears immediately in the app's project list with both\n 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\": \"...\"?, \"duplicate_working_dirs\": [\n {\"working_dir\": \"...\", \"project_id\": \"...\", \"project_name\": \"...\"}\n ]?} — the project appears immediately in the app's project list with both\n 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
244 changes: 244 additions & 0 deletions src/features/berdctl/__tests__/commands/commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const mocks = vi.hoisted(() => ({
updateSessionTitle: vi.fn(),
moveSessionToProject: vi.fn(),
listProjects: vi.fn(),
listAllProjects: vi.fn(),
createProject: vi.fn(),
archiveProject: vi.fn(),
updateProject: vi.fn(),
Expand Down Expand Up @@ -187,7 +188,9 @@ 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),
listAllProjects: (...args: unknown[]) => mocks.listAllProjects(...args),
createProject: (...args: unknown[]) => mocks.createProject(...args),
archiveProject: (...args: unknown[]) => mocks.archiveProject(...args),
updateProject: (...args: unknown[]) => mocks.updateProject(...args),
Expand Down Expand Up @@ -440,6 +443,7 @@ beforeEach(() => {
nextCursor: null,
});
mocks.listProjects.mockResolvedValue([]);
mocks.listAllProjects.mockResolvedValue([]);
mocks.listPersonas.mockResolvedValue([]);
mocks.listSkills.mockResolvedValue([]);
mocks.getVoiceConversationStatus.mockResolvedValue({
Expand Down Expand Up @@ -3847,10 +3851,106 @@ describe("projects", () => {
["/work", "/docs"],
false,
undefined,
undefined,
expect.any(Function),
);
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:
'A working directory is already attached to project "Existing Project" (existing); the new project was created anyway.',
duplicate_working_dirs: [
{
working_dir: "/work",
project_id: "existing",
project_name: "Existing Project",
},
],
});
});

it("create reports every conflicting working directory", async () => {
mocks.listProjects.mockResolvedValue([
makeProject({
id: "existing-work",
name: "Work Project",
workingDirs: ["/work"],
}),
makeProject({
id: "existing-docs",
name: "Docs Project",
workingDirs: ["/docs"],
}),
]);
mocks.createProject.mockResolvedValue(makeProject({ id: "p-new" }));

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

expect(result).toEqual({
project_id: "p-new",
warning:
'A working directory is already attached to project "Work Project" (existing-work); the new project was created anyway.',
duplicate_working_dirs: [
{
working_dir: "/work",
project_id: "existing-work",
project_name: "Work Project",
},
{
working_dir: "/docs",
project_id: "existing-docs",
project_name: "Docs Project",
},
],
});
});

it("create succeeds when the home directory lookup fails", async () => {
mocks.createProject.mockResolvedValue(makeProject({ id: "p-new" }));
mocks.getHomeDir.mockRejectedValue(new Error("system API unavailable"));

await expect(
dispatchCommand(
"projects",
{
action: "create",
name: "My Project",
working_dir: ["~/work"],
},
ctx,
),
).resolves.toEqual({ project_id: "p-new" });
});

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 +5282,150 @@ 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: "Stale Display Name" })],
hasFetchedProjects: true,
});
mocks.listAllProjects.mockResolvedValue([
makeProject({ id: "project-9", name: "Renamed Display Name" }),
]);
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).toMatchObject({
active_project_id: "project-9",
active_project_name: "Renamed Display Name",
});
expect(mocks.listAllProjects).toHaveBeenCalledTimes(1);
});

it("get_context loads the active project name when the local project store is empty", async () => {
controller.getAppContext.mockReturnValue({
view: "chat",
activeSessionId: "session-2",
activeProjectId: "project-9",
});
mocks.listAllProjects.mockResolvedValue([
makeProject({ id: "project-9", name: "Current Display Name" }),
]);

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

expect(result).toMatchObject({
active_project_id: "project-9",
active_project_name: "Current Display Name",
});
expect(mocks.listAllProjects).toHaveBeenCalledTimes(1);
});

it("get_context resolves an archived active project name from current data", async () => {
controller.getAppContext.mockReturnValue({
view: "chat",
activeSessionId: "session-2",
activeProjectId: "project-9",
});
mocks.listAllProjects.mockResolvedValue([
makeProject({
id: "project-9",
name: "Archived Project",
archivedAt: "2026-09-23T00:00:00.000Z",
}),
]);

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

expect(result).toMatchObject({
active_project_id: "project-9",
active_project_name: "Archived Project",
});
});

it("get_context preserves context when the project lookup fails", async () => {
controller.getAppContext.mockReturnValue({
view: "chat",
activeSessionId: "session-2",
activeProjectId: "project-9",
});
mocks.listAllProjects.mockRejectedValue(new Error("backend unavailable"));

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

expect(result).toMatchObject({
view: "chat",
active_session_id: "session-2",
active_project_id: "project-9",
active_project_name: null,
});
});

it("get_context does not refresh projects when no project is active", async () => {
controller.getAppContext.mockReturnValue({
view: "home",
activeSessionId: null,
activeProjectId: null,
});

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

expect(result).toMatchObject({
active_project_id: null,
active_project_name: null,
});
expect(mocks.listAllProjects).not.toHaveBeenCalled();
});

it("get_context reports app and active voice context", async () => {
controller.getAppContext.mockReturnValue({
view: "chat",
Expand Down
Loading
Loading