diff --git a/.bb/skills/verify-bb/features/projects-environments.md b/.bb/skills/verify-bb/features/projects-environments.md index bd5e9a5f1e..af30ded59a 100644 --- a/.bb/skills/verify-bb/features/projects-environments.md +++ b/.bb/skills/verify-bb/features/projects-environments.md @@ -13,30 +13,32 @@ command’s `--help` before mutation. Use fresh browser snapshots for controls. ## Source -- `apps/app/src/views/ProjectSettingsView.tsx` +- `apps/app/src/views/ProjectDetailSettingsView.tsx` - `apps/app/src/components/project/ProjectActionsMenu.tsx` - `apps/cli/src/commands/project.ts` - `apps/cli/src/commands/environment.ts` +Legacy `/projects/:projectId/settings` bookmarks redirect with history replacement to `/settings/projects/:projectId`, preserving query and hash. Check the sidebar, project actions, header, and machine checkout links against the same detail page. + ## Feature recipes -| Feature | Drive | Observable success | -| --- | --- | --- | -| Create and rename projects | Run the local-project recipe, then rename through the project actions menu and reload; compare project show/update. | Project identity remains stable while its name changes. | -| Multiple sources and default source | Add a synthetic source on a second disposable host, change its path/default flag, then remove it with project source operations. Each host permits one source and the source host is immutable. | Sources persist, the intended default is selected, and the remaining source is still usable. | -| Git remote projects | Create a local-path project, then clone a disposable remote onto a second host using project source add --clone and the UI source controls. | Clone/provisioning uses the requested remote and branch; invalid remote errors do not create a usable fake checkout. | -| Recent repository import | On a disposable host home with synthetic recent repos, run the offered import action and inspect project list. | Only discovered candidates are imported; duplicates and missing paths are handled. Do not use real recent repos as fixtures. | -| Local versus managed worktree | Create one thread with Work locally and one with a new worktree and selected base branch. | Environment path, branch, and lifecycle match the selection; edits in the managed worktree do not affect the original checkout. | -| Reuse and switch environments | Select an existing environment for another thread; use environment update for display name/merge-base changes; test path switching separately through the thread environment-directory action after reading help. | Both thread details identify the intended environment; invalid paths fail without silently changing scope. | -| Environment status and branch discovery | Compare Info panel with environment show/status/branches and project branches for the same source. | Branch, dirty state, host, and path agree; disconnected or missing workspaces show an actionable error. | -| Diff views and selected patches | Open Diff with tracked edits, additions, renames, and deletions; use environment diff/diff-files/diff-file/diff-patch. | File lists, old/new contents, line numbers, and selected patches match git diff including untracked changes as supported. | -| Commit | Prepare a fixture containing only changes intended for a commit; invoke the UI/CLI Commit action and inspect git show and the clean diff. | The action stages all workspace changes with git add -A; the resulting commit contains the fixture changes. | -| Pull requests | With a disposable authenticated remote PR, inspect environment pull-request show; exercise ready, draft, and merge only in that test repo. | Forge state agrees with UI/CLI; missing auth/checks/conflicts produce explicit failures. Never run this on a user PR for documentation. | -| Archive environment threads | Create threads in two managed worktree environments and invoke environment archive-threads for one; try a local environment separately. | Only the selected managed environment’s active threads are archived; local environments are rejected with HTTP409. | -| Project attachments and history | Upload/download a synthetic file with project attachment; compare bytes; inspect project history and workspace file/path/content commands. | Returned content and history belong to the chosen project/host; missing files report failure. | -| Execution defaults | Set project defaults for environment/provider/model/permissions, open a new root draft and override one choice before sending. | Resolved defaults populate once, explicit draft choices win, and thread details reflect the actual execution options. | -| Clone destination and folder discovery | Browse an empty test host directory and inspect suggested clone path, path existence and invalid destination feedback. | Folder and clone suggestions target the chosen host; existing paths are not overwritten by a failed clone. | -| Delete project | Delete a disposable project through its confirmation flow, then inspect projects and its threads. | Deletion scope matches the confirmation; cancel leaves all state intact. | +| Feature | Drive | Observable success | +| --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Create and rename projects | Run the local-project recipe, then rename through the project actions menu and reload; compare project show/update. | Project identity remains stable while its name changes. | +| Multiple sources and default source | Add a synthetic source on a second disposable host, change its path/default flag, then remove it with project source operations. Each host permits one source and the source host is immutable. | Sources persist, the intended default is selected, and the remaining source is still usable. | +| Git remote projects | Create a local-path project, then clone a disposable remote onto a second host using project source add --clone and the UI source controls. | Clone/provisioning uses the requested remote and branch; invalid remote errors do not create a usable fake checkout. | +| Recent repository import | On a disposable host home with synthetic recent repos, run the offered import action and inspect project list. | Only discovered candidates are imported; duplicates and missing paths are handled. Do not use real recent repos as fixtures. | +| Local versus managed worktree | Create one thread with Work locally and one with a new worktree and selected base branch. | Environment path, branch, and lifecycle match the selection; edits in the managed worktree do not affect the original checkout. | +| Reuse and switch environments | Select an existing environment for another thread; use environment update for display name/merge-base changes; test path switching separately through the thread environment-directory action after reading help. | Both thread details identify the intended environment; invalid paths fail without silently changing scope. | +| Environment status and branch discovery | Compare Info panel with environment show/status/branches and project branches for the same source. | Branch, dirty state, host, and path agree; disconnected or missing workspaces show an actionable error. | +| Diff views and selected patches | Open Diff with tracked edits, additions, renames, and deletions; use environment diff/diff-files/diff-file/diff-patch. | File lists, old/new contents, line numbers, and selected patches match git diff including untracked changes as supported. | +| Commit | Prepare a fixture containing only changes intended for a commit; invoke the UI/CLI Commit action and inspect git show and the clean diff. | The action stages all workspace changes with git add -A; the resulting commit contains the fixture changes. | +| Pull requests | With a disposable authenticated remote PR, inspect environment pull-request show; exercise ready, draft, and merge only in that test repo. | Forge state agrees with UI/CLI; missing auth/checks/conflicts produce explicit failures. Never run this on a user PR for documentation. | +| Archive environment threads | Create threads in two managed worktree environments and invoke environment archive-threads for one; try a local environment separately. | Only the selected managed environment’s active threads are archived; local environments are rejected with HTTP409. | +| Project attachments and history | Upload/download a synthetic file with project attachment; compare bytes; inspect project history and workspace file/path/content commands. | Returned content and history belong to the chosen project/host; missing files report failure. | +| Execution defaults | Set project defaults for environment/provider/model/permissions, open a new root draft and override one choice before sending. | Resolved defaults populate once, explicit draft choices win, and thread details reflect the actual execution options. | +| Clone destination and folder discovery | Browse an empty test host directory and inspect suggested clone path, path existence and invalid destination feedback. | Folder and clone suggestions target the chosen host; existing paths are not overwritten by a failed clone. | +| Delete project | Delete a disposable project through its confirmation flow, then inspect projects and its threads. | Deletion scope matches the confirmation; cancel leaves all state intact. | ## Evidence and cleanup @@ -49,8 +51,8 @@ recipe. External writes require a disposable test target and task authorization. ## Maintenance notes -- Create a local project, then use its project actions menu → Rename and reload; compare source project show/update. Project settings contains source controls, not Rename. Source: `apps/app/src/components/project/ProjectActionsMenu.tsx:87; apps/cli/src/commands/project.ts:530`. -- Use two disposable hosts: each project permits one source per host. Select host when adding; update path/default with source operations. Move between hosts by adding/removing sources, not by changing a source host. Source: `apps/cli/src/commands/project.ts:574; apps/app/src/views/ProjectSettingsView.tsx:173`. +- Create a local project, then use its project actions menu → Rename and reload; compare source project show/update. Project settings opens Settings → Projects → project detail, including rename, checkouts, thread defaults, project information, and deletion. Source: `apps/app/src/components/project/ProjectActionsMenu.tsx:87; apps/cli/src/commands/project.ts:530`. +- Use two disposable hosts: each project permits one source per host. Select host when adding; update path/default with source operations. Move between hosts by adding/removing sources, not by changing a source host. Source: `apps/cli/src/commands/project.ts:574; apps/app/src/views/ProjectDetailSettingsView.tsx`. - Create a project from a local path, then use project source add --clone --remote-url --target-path on a second host. project create does not accept a remote URL. Source: `apps/cli/src/commands/project.ts:487; apps/cli/src/commands/project.ts:574`. - Await provisioning with thread show; its JSON wraps thread and environment. Initial spawn may have environmentId null. Source: `apps/cli/src/commands/thread/spawn.ts; apps/cli/src/commands/environment.ts:301`. - Reuse an environment with thread spawn --environment. environment update supports display name and merge-base override only. Test path switching with the supported thread environment-directory action separately. Source: `apps/cli/src/commands/environment.ts:579; apps/cli/src/commands/thread/spawn.ts`. diff --git a/apps/app/src/App.project-settings-routes.test.tsx b/apps/app/src/App.project-settings-routes.test.tsx new file mode 100644 index 0000000000..dfbaa68e86 --- /dev/null +++ b/apps/app/src/App.project-settings-routes.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + MemoryRouter, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; +import { AppRoutes } from "./App"; + +vi.mock("./components/layout/AppLayout", () => ({ + AppLayout: ({ children }: { children: ReactNode }) => <>{children}, +})); +vi.mock("./views/ProjectDetailSettingsView", () => ({ + ProjectDetailSettingsView: () => { + const { projectId } = useParams(); + return

Project detail: {projectId}

; + }, +})); +vi.mock("./views/SettingsView", () => ({ + SettingsView: () =>

Settings

, +})); + +function NavigationProbe() { + const location = useLocation(); + const navigate = useNavigate(); + return ( + <> + + {location.pathname} + {location.search} + {location.hash} + + + + ); +} + +function renderRoute(path: string) { + render( + + + + , + ); +} + +afterEach(cleanup); + +describe("project settings routes", () => { + it.each([ + "proj_example", + "proj_missing", + "proj_personal", + "project with spaces", + ])("opens the existing detail for legacy project %s", async (projectId) => { + renderRoute( + `/projects/${encodeURIComponent(projectId)}/settings?from=bookmark#checkouts`, + ); + expect( + await screen.findByRole("heading", { + name: `Project detail: ${projectId}`, + }), + ).toBeTruthy(); + expect(screen.getByRole("status").textContent).toBe( + `/settings/projects/${encodeURIComponent(projectId)}?from=bookmark#checkouts`, + ); + fireEvent.click(screen.getByRole("button", { name: "Back" })); + expect( + await screen.findByRole("heading", { name: "Settings" }), + ).toBeTruthy(); + expect(screen.getByRole("status").textContent).toBe("/settings"); + }); + + it("keeps the Settings detail destination directly accessible", async () => { + renderRoute("/settings/projects/proj_example"); + expect( + await screen.findByRole("heading", { + name: "Project detail: proj_example", + }), + ).toBeTruthy(); + }); +}); diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx index 013d41b3d7..f1dcff2b8d 100644 --- a/apps/app/src/App.tsx +++ b/apps/app/src/App.tsx @@ -35,7 +35,7 @@ import { LEGACY_TOOLS_SPLAT_ROUTE_PATH, PROJECT_ARCHIVED_ROUTE_PATH, PROJECTLESS_ARCHIVED_ROUTE_PATH, - PROJECT_SETTINGS_ROUTE_PATH, + LEGACY_PROJECT_SETTINGS_ROUTE_PATH, SETTINGS_PLUGIN_ROUTE_PATH, SETTINGS_PLUGINS_ROUTE_PATH, SETTINGS_MACHINE_ROUTE_PATH, @@ -53,6 +53,7 @@ import { getAutomationEditRoutePath, getAutomationsRoutePath, getSettingsRoutePath, + getSettingsProjectRoutePath, getSkillDetailRoutePath, } from "./lib/route-paths"; import { AppCommandProvider } from "./components/commands/AppCommandProvider"; @@ -79,15 +80,27 @@ const MachineSettingsView = lazy(() => default: m.MachineSettingsView, })), ); -const ProjectSettingsView = lazy(() => - import("./views/ProjectSettingsView").then((m) => ({ - default: m.ProjectSettingsView, - })), -); const splitWorkspaceRouteModule = import("./views/SplitWorkspaceRoute"); splitWorkspaceRouteModule.catch(() => {}); const SplitWorkspaceRoute = lazy(() => splitWorkspaceRouteModule); +function LegacyProjectSettingsRedirect() { + const { projectId } = useParams<{ projectId: string }>(); + const { search, hash } = useLocation(); + return ( + + ); +} + export function LegacyAutomationDetailRedirect() { const location = useLocation(); const { projectId, automationId } = useParams<{ @@ -208,7 +221,7 @@ export function HashNavigationScroll() { return null; } -function AppRoutes() { +export function AppRoutes() { return ( @@ -232,8 +245,8 @@ function AppRoutes() { element={} /> } + path={LEGACY_PROJECT_SETTINGS_ROUTE_PATH} + element={} /> @@ -387,14 +382,8 @@ export function AppLayout({ children }: AppLayoutProps) { restoreIOSViewportOnKeyboardDismissal, ); const location = useLocation(); - const { - projectId, - threadId, - isThreadView, - isArchivedView, - isSettingsView, - isRootView, - } = useRouteState(); + const { projectId, threadId, isThreadView, isArchivedView, isRootView } = + useRouteState(); const [resourceRouteLabel, setResourceRouteLabel] = useAtom( resourceRouteLabelAtom, ); @@ -606,22 +595,11 @@ export function AppLayout({ children }: AppLayoutProps) { { label: "Archived" }, ], } - : isSettingsView && projectId + : projectId ? { - title: "", - breadcrumbs: [ - { - label: projectLabel ?? projectId, - to: getLegacyProjectComposeRoutePath(projectId), - }, - { label: "Settings" }, - ], + title: projectLabel ?? projectId, } - : projectId - ? { - title: projectLabel ?? projectId, - } - : (resolveRouteTitle(location.pathname) ?? { title: "" }); + : (resolveRouteTitle(location.pathname) ?? { title: "" }); const documentTitle = (() => { if (isThreadView) { @@ -645,9 +623,6 @@ export function AppLayout({ children }: AppLayoutProps) { } return `${projectLabel ?? projectId} · Archived`; } - if (isSettingsView && projectId) { - return `${projectLabel ?? projectId} · Settings`; - } if (projectId) { return projectLabel ?? projectId; } @@ -779,10 +754,7 @@ export function AppLayout({ children }: AppLayoutProps) { {showHeader ? ( { - navigate(getProjectSettingsRoutePath(project.id)); + navigate(getSettingsProjectRoutePath(project.id)); }} > Project settings diff --git a/apps/app/src/components/settings/ProjectsSettingsSection.tsx b/apps/app/src/components/settings/ProjectsSettingsSection.tsx index 51cb23be5f..e274e04b60 100644 --- a/apps/app/src/components/settings/ProjectsSettingsSection.tsx +++ b/apps/app/src/components/settings/ProjectsSettingsSection.tsx @@ -176,6 +176,7 @@ function SortableProjectRow({ ref={setNodeRef} style={style} className={cn( + "items-start", isDragging && "relative z-10 rounded-md bg-card opacity-90 shadow-lift", )} > @@ -197,39 +198,41 @@ function SortableProjectRow({
-
+ {project.name} {needsSetup ? needs setup : null} {allOffline ? offline : null}
-
+
{remoteLabel === null ? ( - No git remote +
No git remote
) : ( - {remoteLabel} +
{remoteLabel}
)} - - {machineLabel(summary)} - - - {pluralize(project.threads.length, "thread")} - +
+ + {machineLabel(summary)} + + + {pluralize(project.threads.length, "thread")} + +
diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index 4344b8cb9d..0534c8038a 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -79,7 +79,7 @@ import { } from "@bb/client-core"; import { cn } from "@bb/shared-ui/lib/utils"; import { getMutationErrorMessage } from "@/lib/mutation-errors"; -import { getProjectSettingsRoutePath } from "@/lib/route-paths"; +import { getSettingsProjectRoutePath } from "@/lib/route-paths"; import { getThreadDisplayTitle } from "@/lib/thread-title"; import { appToast } from "@/components/ui/app-toast"; import { useRouteNavigate } from "@/components/ui/app-route-anchor"; @@ -2363,7 +2363,7 @@ function ProjectRowComponent({ ) : null} {isLocalPathInvalid ? ( { event.stopPropagation(); onProjectSelect?.(); diff --git a/apps/app/src/hooks/useAppSettingsRouteMemory.test.tsx b/apps/app/src/hooks/useAppSettingsRouteMemory.test.tsx index 060bdbcccc..7ddfed9197 100644 --- a/apps/app/src/hooks/useAppSettingsRouteMemory.test.tsx +++ b/apps/app/src/hooks/useAppSettingsRouteMemory.test.tsx @@ -31,6 +31,8 @@ function RouteMemoryTestSurface() { Codex settings + Legacy project settings + Project detail Legacy plugin collection Legacy plugin detail @@ -40,6 +42,33 @@ function RouteMemoryTestSurface() { describe("useAppSettingsRouteMemory", () => { afterEach(cleanup); + it.each(["/projects/proj_one/settings", "/settings/projects/proj_one"])( + "uses a safe app destination when opening %s directly", + (path) => { + render( + + + , + ); + fireEvent.click(screen.getByRole("link", { name: "App" })); + expect(screen.getByTestId("location").textContent).toBe("/"); + }, + ); + + it("preserves the previous app route across a legacy project settings redirect", () => { + render( + + + , + ); + fireEvent.click( + screen.getByRole("link", { name: "Legacy project settings" }), + ); + fireEvent.click(screen.getByRole("link", { name: "Project detail" })); + fireEvent.click(screen.getByRole("link", { name: "App" })); + expect(screen.getByTestId("location").textContent).toBe("/threads/thr_one"); + }); + it("switches between the most recent app and settings routes", () => { render( { + it.each(["/projects/proj_one/settings", "/settings/projects/proj_one"])( + "resolves project settings links inside the app: %s", + (path) => { + const suffix = "?from=bookmark#checkouts"; + expect( + resolveRouteHref({ + currentOrigin: "https://bb.example", + href: `https://bb.example${path}${suffix}`, + }), + ).toEqual({ path: `${path}${suffix}` }); + }, + ); + it("recognizes the legacy archived URL", () => { expect(isRoutePath({ path: "/archived" })).toBe(true); }); diff --git a/apps/app/src/lib/route-paths.ts b/apps/app/src/lib/route-paths.ts index e308a15101..2d6a97eaa3 100644 --- a/apps/app/src/lib/route-paths.ts +++ b/apps/app/src/lib/route-paths.ts @@ -42,7 +42,7 @@ export { SKILLS_ROUTE_PATH, LEGACY_PROJECT_COMPOSE_ROUTE_PATH, PROJECTLESS_ARCHIVED_ROUTE_PATH, - PROJECT_SETTINGS_ROUTE_PATH, + LEGACY_PROJECT_SETTINGS_ROUTE_PATH, PROJECT_ARCHIVED_ROUTE_PATH, PLUGIN_PANEL_ROUTE_PATH, isProjectlessProjectId, @@ -62,7 +62,6 @@ export { getAutomationsRoutePath, getAutomationDetailRoutePath, getAutomationEditRoutePath, - getProjectSettingsRoutePath, getPluginPanelRoutePath, getThreadRoutePath, } from "@bb/client-core"; diff --git a/apps/app/src/views/MachineSettingsView.tsx b/apps/app/src/views/MachineSettingsView.tsx index ed47a00ff8..6898a698ad 100644 --- a/apps/app/src/views/MachineSettingsView.tsx +++ b/apps/app/src/views/MachineSettingsView.tsx @@ -45,7 +45,7 @@ import { formatRelativeTime } from "@/lib/relative-time"; import { ProviderIconMark } from "@/components/settings/ProviderIconMark"; import { getProviderIconInfo } from "@/lib/provider-icon"; import { - getProjectSettingsRoutePath, + getSettingsProjectRoutePath, getSettingsRoutePath, } from "@/lib/route-paths"; @@ -411,7 +411,7 @@ export function MachineSettingsView() { {index > 0 ? " · " : ""} {project.name} diff --git a/apps/app/src/views/ProjectDetailSettingsView.tsx b/apps/app/src/views/ProjectDetailSettingsView.tsx index c16d10bfd1..30e0050a5b 100644 --- a/apps/app/src/views/ProjectDetailSettingsView.tsx +++ b/apps/app/src/views/ProjectDetailSettingsView.tsx @@ -115,41 +115,37 @@ function CheckoutRow({ }: CheckoutRowProps) { const connected = host.status === "connected"; return ( - -
- -
-
- - - {host.name} - - {isPrimary ? primary : null} - {isPathInvalid ? ( - Path not found - ) : null} -
-
- {source === null ? ( - - Not set up on this machine - - ) : ( - source.path + +
+
+ + /> + + + {host.name} + + {isPrimary ? primary : null} + {isPathInvalid ? ( + Path not found + ) : null} +
+
+ {source === null ? ( + Not set up on this machine + ) : ( + source.path + )}
-
+
{source === null ? ( - - - {hosts.map((host) => { - const hasSource = Boolean( - findLocalPathProjectSourceForHost(sources, host.id), - ); - const connected = host.status === "connected"; - return ( - { - if (host.id === pickerHostId) { - openAddLocalSourcePicker(); - } else { - openMachineSetup(host); - } - }} - > - - {host.name} - {hasSource ? ( - - Already added - - ) : !connected ? ( - - Offline - - ) : null} - - ); - })} - - -
- ) : showAddLocalSourceButton ? ( -
- -
- ) : null; - - return ( - -
- - {isLoading ? ( -

Loading…

- ) : sources.length === 0 ? ( -
-

- No sources configured. -

- {addSourceButtons} -
- ) : ( -
- - {sources.map((source) => { - const isPickerHostSource = - pickerHostId != null && source.hostId === pickerHostId; - const isInvalid = - isPickerHostSource && - isHostPathMissing(pathExistence, source.path); - const machineHost = multipleMachines - ? hostById.get(source.hostId) - : undefined; - return ( - - setDeleteTarget({ - id: target.id, - label: target.path, - }) - } - /> - ); - })} - - {addSourceButtons} -
- )} -
-
- - - - { - if (!open) setMachineSetupTarget(null); - }} - onComplete={() => setMachineSetupTarget(null)} - /> - - { - if (!open) setDeleteTarget(null); - }} - onDelete={(sourceId) => { - if (!projectId) return; - deleteSource.mutate( - { projectId, sourceId }, - { onSuccess: () => setDeleteTarget(null) }, - ); - }} - /> -
- ); -} diff --git a/apps/app/src/views/project-settings/ProjectSourceRow.test.tsx b/apps/app/src/views/project-settings/ProjectSourceRow.test.tsx deleted file mode 100644 index 484a50caf0..0000000000 --- a/apps/app/src/views/project-settings/ProjectSourceRow.test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -// @vitest-environment jsdom - -import { - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import type { LocalPathProjectSource } from "@bb/domain"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { ProjectSourceRow } from "./ProjectSourceRow"; - -const source: LocalPathProjectSource = { - id: "src_test", - projectId: "proj_test", - type: "local_path", - hostId: "host_test", - path: "/tmp/test-project", - isDefault: true, - createdAt: 0, - updatedAt: 0, -}; - -describe("ProjectSourceRow", () => { - afterEach(cleanup); - - it("shows the source's machine with its connection status", () => { - render( - , - ); - - expect(screen.getByText("Mac Studio")).toBeDefined(); - expect(screen.getByText("/tmp/test-project")).toBeDefined(); - expect(screen.queryByText("offline")).toBeNull(); - }); - - it("labels the row offline when the machine is disconnected", () => { - render( - , - ); - - expect(screen.getByText("dev-vm")).toBeDefined(); - expect(screen.getByText("offline")).toBeDefined(); - }); - - it("closes the actions menu after selecting edit local path", async () => { - render( - , - ); - - fireEvent.pointerDown( - screen.getByRole("button", { name: "Source actions" }), - { button: 0 }, - ); - fireEvent.click( - await screen.findByRole("menuitem", { name: "Edit local path" }), - ); - - await waitFor(() => { - expect( - screen.queryByRole("menuitem", { name: "Edit local path" }), - ).toBeNull(); - }); - }); -}); diff --git a/apps/app/src/views/project-settings/ProjectSourceRow.tsx b/apps/app/src/views/project-settings/ProjectSourceRow.tsx deleted file mode 100644 index ae00fac9a5..0000000000 --- a/apps/app/src/views/project-settings/ProjectSourceRow.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { type LocalPathProjectSource, type ProjectSource } from "@bb/domain"; -import { Icon } from "@bb/shared-ui/icon"; -import { cn } from "@bb/shared-ui/lib/utils"; -import { SettingsRow } from "@/components/ui/settings-section.js"; -import { Pill } from "@bb/shared-ui/pill"; -import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; -import { PersistentHostIconName } from "@/lib/host-display"; -import { Button } from "@bb/shared-ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@bb/shared-ui/dropdown-menu"; - -interface ProjectSourceRowMachine { - name: string; - connected: boolean; -} - -interface ProjectSourceRowProps { - source: ProjectSource; - machine: ProjectSourceRowMachine | null; - canEditLocalPath: boolean; - isLocalPathInvalid: boolean; - isEditPending: boolean; - isOnlySource: boolean; - onEditLocalPath: (source: LocalPathProjectSource) => void; - onRemove: (source: ProjectSource) => void; -} - -export function ProjectSourceRow({ - source, - machine, - canEditLocalPath, - isLocalPathInvalid, - isEditPending, - isOnlySource, - onEditLocalPath, - onRemove, -}: ProjectSourceRowProps) { - const isOffline = machine !== null && !machine.connected; - return ( - - - - {machine !== null ? ( - - - - {machine.name} - - - ) : null} - {source.path} - {isLocalPathInvalid ? ( - Invalid local path - ) : null} - {isOffline ? ( - - offline - - ) : null} - - - - - - - {canEditLocalPath ? ( - { - onEditLocalPath(source); - }} - > - Edit local path - - ) : null} - onRemove(source)} - > - Remove - - - - - ); -} diff --git a/packages/client-core/src/routes/route-paths.ts b/packages/client-core/src/routes/route-paths.ts index 6cdbd76da8..66306b53a3 100644 --- a/packages/client-core/src/routes/route-paths.ts +++ b/packages/client-core/src/routes/route-paths.ts @@ -47,7 +47,8 @@ const ROOT_COMPOSE_ROUTE_PATH = APP_ROOT_ROUTE_PATH; export const LEGACY_PROJECT_COMPOSE_ROUTE_PATH = "/projects/:projectId"; export const PROJECTLESS_ARCHIVED_ROUTE_PATH = "/archived"; const PROJECTLESS_THREAD_DETAIL_ROUTE_PATH = "/threads/:threadId"; -export const PROJECT_SETTINGS_ROUTE_PATH = "/projects/:projectId/settings"; +export const LEGACY_PROJECT_SETTINGS_ROUTE_PATH = + "/projects/:projectId/settings"; export const PROJECT_ARCHIVED_ROUTE_PATH = "/projects/:projectId/archived"; const THREAD_DETAIL_ROUTE_PATH = "/projects/:projectId/threads/:threadId"; export const PLUGIN_PANEL_ROUTE_PATH = "/plugins/:pluginId/:panelPath/*"; @@ -170,10 +171,6 @@ export function getAutomationEditRoutePath( return `${getAutomationDetailRoutePath(args)}/edit`; } -export function getProjectSettingsRoutePath(projectId: string): string { - return `/projects/${projectId}/settings`; -} - interface PluginPanelRoutePathArgs { pluginId: string; path: string; @@ -208,6 +205,7 @@ const baseRoutePatterns: readonly string[] = [ AUTH_CALLBACK_ROUTE_PATH, SETTINGS_ROUTE_PATH, SETTINGS_SECTION_ROUTE_PATH, + SETTINGS_PROJECT_ROUTE_PATH, SETTINGS_PLUGINS_ROUTE_PATH, SETTINGS_PLUGIN_ROUTE_PATH, TOOLS_ROUTE_PATH, @@ -234,7 +232,7 @@ const baseRoutePatterns: readonly string[] = [ AUTOMATION_EDIT_ROUTE_PATH, LEGACY_PROJECT_COMPOSE_ROUTE_PATH, PROJECTLESS_ARCHIVED_ROUTE_PATH, - PROJECT_SETTINGS_ROUTE_PATH, + LEGACY_PROJECT_SETTINGS_ROUTE_PATH, PROJECT_ARCHIVED_ROUTE_PATH, PROJECTLESS_THREAD_DETAIL_ROUTE_PATH, THREAD_DETAIL_ROUTE_PATH, diff --git a/plans/bb-mobile-research/ui-inventory.md b/plans/bb-mobile-research/ui-inventory.md index 4f915eecfd..52357cd074 100644 --- a/plans/bb-mobile-research/ui-inventory.md +++ b/plans/bb-mobile-research/ui-inventory.md @@ -10,7 +10,8 @@ Providers, outer→inner: `AppErrorBoundary` (main.tsx:60; class boundary, fallb | `/settings/plugins` | redirect → `/extensions/plugins?view=installed` | | | `/settings/plugins/:pluginId` | SettingsView → PluginSettingsPage | plugin config | | `/settings/machines/:hostId` | MachineSettingsView | permission limit cards, details, rename/remove | -| `/projects/:projectId/settings` | ProjectSettingsView | Project Sources list, add/edit/remove | +| `/settings/projects/:projectId` | ProjectDetailSettingsView | project details, defaults, checkouts, rename/delete | +| `/projects/:projectId/settings` | redirect → `/settings/projects/:projectId` | legacy bookmark compatibility; preserves query/hash | | `/projects/:projectId/archived`, `/archived` | redirect → `/settings/archived` | | | `/extensions` → `/extensions/plugins`; `/extensions/plugins/:pluginId`; `/extensions/skills`, `/extensions/skills/library/:skillId`, `/extensions/skills/registry[/:id]` | ToolsView | Plugins browse/installed (`?view=installed|create`), Skills browse/library | | `/tools/*`, `/skills`, `/automations*` | legacy redirects | automations now `/plugins/automations/automations[/browse|/:projectId/:automationId[/edit]]` | @@ -72,7 +73,7 @@ Path params via `useRouteState` (hooks/useRouteState.ts). Query: `?view=browse|i - apps/app/src/components/settings/settings-nav.tsx - apps/app/src/components/settings/SettingsSidebar.tsx - apps/app/src/views/MachineSettingsView.tsx -- apps/app/src/views/ProjectSettingsView.tsx +- apps/app/src/views/ProjectDetailSettingsView.tsx - apps/app/src/views/ToolsView.tsx - apps/app/src/components/tools/tools-navigation.ts - apps/app/src/components/tools/ToolsSidebar.tsx