Skip to content

Commit 82c3dc0

Browse files
feat(org): project view reads Projects from the Project API
Projects come from /api/projects instead of grouping workspaces by fork lineage on the client. A project's environments are its workspaces, named by the workspace, ordered roots first with each fork after its parent. The routes keep the environment's workspace in the URL (/o/<org>/p/<workspaceId>/<section>) since every section but Environments shows one environment and Chat reads the workspace from the route; the page resolves its project through /api/projects/by-workspace. Rename and delete act on the project (PATCH/DELETE /api/projects/[id]) and need administer. Pins stay on workspaces: a project reads as pinned while any environment is. Workspace, fork and unlink mutations now invalidate the project caches, since each changes a project's environments. The dashboard tab renders the environment's saved dashboard (one per workspace on staging) beside the Runs dashboard. lib/projects/lineage.ts is gone. org-project-view needs the `projects` flag on as well; Project API errors surface in place rather than falling back. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Lrd1NLD9qx2KBLVnw32c3E
1 parent 574eb19 commit 82c3dc0

41 files changed

Lines changed: 412 additions & 512 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1 @@
1-
export { ProjectRow } from './project-row'
21
export { ProjectsSection } from './projects-section'
3-
export { useProjectActions } from './use-project-actions'
4-
export { type ProjectDragProps, useProjectOrder } from './use-project-order'

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/project-row.tsx‎

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ export function ProjectRow({
176176
}}
177177
onDoubleClick={(event) => {
178178
event.preventDefault()
179-
rename.handleStartEdit()
179+
if (project.canAdminister) rename.handleStartEdit()
180180
}}
181181
aria-expanded={expanded}
182182
className='flex min-w-0 flex-1 items-center gap-2'
@@ -243,19 +243,25 @@ export function ProjectRow({
243243
<Duplicate />
244244
Copy link
245245
</DropdownMenuItem>
246-
<DropdownMenuItem onSelect={() => rename.handleStartEdit()}>
247-
<Pencil />
248-
Rename
249-
</DropdownMenuItem>
246+
{project.canAdminister && (
247+
<DropdownMenuItem onSelect={() => rename.handleStartEdit()}>
248+
<Pencil />
249+
Rename
250+
</DropdownMenuItem>
251+
)}
250252
<DropdownMenuItem onSelect={() => actions.setPinned(project, !pinned)}>
251253
{pinned ? <PinOff /> : <Pin />}
252254
{pinned ? 'Unpin' : 'Pin'}
253255
</DropdownMenuItem>
254-
<DropdownMenuSeparator />
255-
<DropdownMenuItem onSelect={() => setDeleteOpen(true)}>
256-
<Trash />
257-
Delete
258-
</DropdownMenuItem>
256+
{project.canAdminister && (
257+
<>
258+
<DropdownMenuSeparator />
259+
<DropdownMenuItem onSelect={() => setDeleteOpen(true)}>
260+
<Trash />
261+
Delete
262+
</DropdownMenuItem>
263+
</>
264+
)}
259265
</DropdownMenuContent>
260266
</DropdownMenu>
261267

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/projects-section.tsx‎

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import {
1111
type ProjectDragProps,
1212
useProjectOrder,
1313
} from '@/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-order'
14-
import { type Project, useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects'
14+
import {
15+
defaultEnvironment,
16+
type Project,
17+
useProjects,
18+
} from '@/app/o/[organizationId]/p/hooks/use-projects'
1519
import { projectParsers } from '@/app/o/[organizationId]/p/search-params'
1620
import { SidebarSection } from '@/app/workspace/[workspaceId]/w/components/sidebar/components'
1721
import { SIDEBAR_ITEM_GAP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants'
@@ -26,14 +30,19 @@ interface ProjectsSectionProps {
2630
pathname: string | null
2731
}
2832

29-
/** The organization's projects, one per fork lineage, each with its chats underneath. */
33+
/** The organization's projects, each with its chats underneath. */
3034
export function ProjectsSection({ organizationId, isCollapsed, pathname }: ProjectsSectionProps) {
31-
const { roots } = useProjects(organizationId)
32-
const { projects, dragProps, isAnyDragActive } = useProjectOrder(roots)
35+
const { projects: unordered, error } = useProjects(organizationId)
36+
const { projects, dragProps, isAnyDragActive } = useProjectOrder(unordered)
3337
const [{ chat: openChat }] = useQueryStates(projectParsers)
3438
return (
3539
<SidebarSection title='Projects' railCollapsed={isCollapsed} className='shrink-0'>
3640
<div className={cn(SIDEBAR_ITEM_GAP_CLASS, 'flex flex-col px-2')}>
41+
{error && !isCollapsed && (
42+
<p role='alert' className='px-2 py-1 text-[var(--text-error)] text-caption'>
43+
{error.message}
44+
</p>
45+
)}
3746
{projects.map((project) => (
3847
<ProjectTree
3948
key={project.id}
@@ -62,8 +71,8 @@ interface ProjectTreeProps {
6271
}
6372

6473
/**
65-
* A project row with its chats listed underneath. A project spans every environment in its
66-
* lineage; the chats shown belong to the environment you are in.
74+
* A project row with its chats listed underneath. The chats shown belong to the environment
75+
* you are in, else to the project's default environment.
6776
*/
6877
function ProjectTree({
6978
organizationId,
@@ -82,19 +91,20 @@ function ProjectTree({
8291
return pathname === base || pathname?.startsWith(`${base}/`)
8392
})?.workspaceId ?? null
8493
const inProject = activeId !== null
94+
const homeWorkspaceId = defaultEnvironment(project).workspaceId
8595
/** null follows navigation (open while you're in the project); a click pins it open or shut. */
8696
const [override, setOverride] = useState<boolean | null>(null)
8797
const expanded = override ?? inProject
88-
const { data: chats = [] } = useMothershipChats(activeId ?? project.id)
98+
const { data: chats = [] } = useMothershipChats(activeId ?? homeWorkspaceId)
8999
const visible = showAll ? chats : chats.slice(0, CHAT_PREVIEW)
90100
const panelHref = (chatId: string) =>
91-
`${inProject && pathname ? pathname : routes.project(project.id)}?chat=${chatId}`
101+
`${inProject && pathname ? pathname : routes.project(homeWorkspaceId)}?chat=${chatId}`
92102

93103
return (
94104
<div className='flex flex-col gap-[1px]'>
95105
<ProjectRow
96106
project={project}
97-
href={routes.project(project.id)}
107+
href={routes.project(homeWorkspaceId)}
98108
newChatHref={panelHref('new')}
99109
active={inProject}
100110
expanded={chats.length > 0 ? expanded : undefined}

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-actions.ts‎

Lines changed: 25 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,55 @@
11
'use client'
22

33
import { useCallback } from 'react'
4-
import { parseEnvironmentName } from '@/lib/projects'
5-
import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects'
6-
import {
7-
useDeleteWorkspace,
8-
usePinnedWorkspaceIds,
9-
useToggleWorkspacePin,
10-
useUpdateWorkspace,
11-
useWorkspacesQuery,
12-
} from '@/hooks/queries/workspace'
4+
import { isProjectPinned } from '@/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-order'
5+
import { defaultEnvironment, type Project } from '@/app/o/[organizationId]/p/hooks/use-projects'
6+
import { useArchiveProject, useRenameProject } from '@/hooks/queries/projects'
7+
import { usePinnedWorkspaceIds, useToggleWorkspacePin } from '@/hooks/queries/workspace'
138

149
/**
15-
* Rename, pin and delete for a project, applied to every workspace of its lineage: a rename
16-
* keeps each environment's own suffix ("Staging", "Sandbox"), a delete removes forks before
17-
* their parent, and the pin lives on the root. Mock projects have no workspace, so every
18-
* action is a no-op for them.
10+
* Rename, pin and delete for a project. Rename and delete act on the project itself; delete
11+
* archives it with every environment. Pins live on workspaces, so pinning pins the project's
12+
* default environment and unpinning clears every environment's pin.
1913
*/
2014
export function useProjectActions() {
21-
const { data: workspaces } = useWorkspacesQuery()
2215
const { data: pinnedIds } = usePinnedWorkspaceIds()
2316
const { mutate: togglePin } = useToggleWorkspacePin()
24-
const { mutateAsync: updateWorkspace, isPending: isRenaming } = useUpdateWorkspace()
25-
const { mutateAsync: deleteWorkspace, isPending: isDeleting } = useDeleteWorkspace()
17+
const { mutateAsync: renameProject, isPending: isRenaming } = useRenameProject()
18+
const { mutateAsync: archiveProject, isPending: isDeleting } = useArchiveProject()
2619

2720
const isPinned = useCallback(
28-
(project: Project) => Boolean(pinnedIds?.has(project.rootId)),
21+
(project: Project) => isProjectPinned(project, pinnedIds),
2922
[pinnedIds]
3023
)
3124

3225
const setPinned = useCallback(
3326
(project: Project, pinned: boolean) => {
34-
togglePin({ workspaceId: project.rootId, pinned })
27+
if (pinned) {
28+
togglePin({ workspaceId: defaultEnvironment(project).workspaceId, pinned })
29+
return
30+
}
31+
for (const environment of project.environments) {
32+
if (pinnedIds?.has(environment.workspaceId))
33+
togglePin({ workspaceId: environment.workspaceId, pinned })
34+
}
3535
},
36-
[togglePin]
36+
[togglePin, pinnedIds]
3737
)
3838

3939
const rename = useCallback(
4040
async (project: Project, name: string) => {
41-
const base = name.trim()
42-
if (!base || base === project.name) return
43-
for (const environment of project.environments) {
44-
const workspace = workspaces?.find((candidate) => candidate.id === environment.workspaceId)
45-
if (!workspace) continue
46-
const { environment: suffix } = parseEnvironmentName(workspace.name)
47-
await updateWorkspace({
48-
workspaceId: workspace.id,
49-
name: suffix ? `${base} ${suffix}` : base,
50-
})
51-
}
41+
const trimmed = name.trim()
42+
if (!trimmed || trimmed === project.name) return
43+
await renameProject({ projectId: project.id, name: trimmed })
5244
},
53-
[updateWorkspace, workspaces]
45+
[renameProject]
5446
)
5547

5648
const remove = useCallback(
5749
async (project: Project) => {
58-
/** Forks first: the root is the last workspace to go, so a failure leaves a valid lineage. */
59-
for (const environment of [...project.environments].reverse()) {
60-
await deleteWorkspace({ workspaceId: environment.workspaceId })
61-
}
50+
await archiveProject({ projectId: project.id })
6251
},
63-
[deleteWorkspace]
52+
[archiveProject]
6453
)
6554

6655
return { isPinned, setPinned, rename, remove, isRenaming, isDeleting }

‎apps/sim/app/o/[organizationId]/components/organization-sidebar/components/projects-section/use-project-order.ts‎

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects'
55
import { usePinnedWorkspaceIds } from '@/hooks/queries/workspace'
66
import { orderProjectIds, useProjectOrderStore } from '@/stores/project-order/store'
77

8+
/** A project reads as pinned while any of its environments' workspaces is. */
9+
export function isProjectPinned(project: Project, pinnedIds: ReadonlySet<string> | undefined) {
10+
return project.environments.some((environment) => pinnedIds?.has(environment.workspaceId))
11+
}
12+
813
/** Where a dragged project would land relative to the row under the pointer. */
914
export interface ProjectDropIndicator {
1015
projectId: string
@@ -27,20 +32,20 @@ export interface ProjectDragProps {
2732
* order, then everything the order does not know yet. Also owns the drag-to-reorder state
2833
* the rows bind; a drop moves the dragged project before or after the row it landed on.
2934
*/
30-
export function useProjectOrder(roots: readonly Project[]) {
35+
export function useProjectOrder(unordered: readonly Project[]) {
3136
const { data: pinnedIds } = usePinnedWorkspaceIds()
3237
const order = useProjectOrderStore((state) => state.order)
3338
const moveProject = useProjectOrderStore((state) => state.moveProject)
3439
const [draggingId, setDraggingId] = useState<string | null>(null)
3540
const [dropIndicator, setDropIndicator] = useState<ProjectDropIndicator | null>(null)
3641

37-
const byId = new Map(roots.map((project) => [project.id, project]))
42+
const byId = new Map(unordered.map((project) => [project.id, project]))
3843
const ordered = orderProjectIds(
39-
roots.map((project) => project.id),
44+
unordered.map((project) => project.id),
4045
order
4146
).map((id) => byId.get(id)!)
42-
const pinned = ordered.filter((project) => pinnedIds?.has(project.rootId))
43-
const unpinned = ordered.filter((project) => !pinnedIds?.has(project.rootId))
47+
const pinned = ordered.filter((project) => isProjectPinned(project, pinnedIds))
48+
const unpinned = ordered.filter((project) => !isProjectPinned(project, pinnedIds))
4449
const projects = [...pinned, ...unpinned]
4550
const visibleIds = projects.map((project) => project.id)
4651

‎apps/sim/app/o/[organizationId]/home/components/project-home/project-home.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,11 @@ import { useRouter } from 'next/navigation'
1616
import { useQueryStates } from 'nuqs'
1717
import { organizationRoutes } from '@/lib/navigation/paths'
1818
import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home'
19-
import { type Project, useProjects } from '@/app/o/[organizationId]/p/hooks/use-projects'
19+
import {
20+
defaultEnvironment,
21+
type Project,
22+
useProjects,
23+
} from '@/app/o/[organizationId]/p/hooks/use-projects'
2024
import { projectParsers } from '@/app/o/[organizationId]/p/search-params'
2125
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
2226

@@ -33,7 +37,7 @@ interface ProjectHomeProps {
3337
export function ProjectHome({ userName }: ProjectHomeProps) {
3438
const router = useRouter()
3539
const { organization } = useOrganizationContext()
36-
const { roots: projects } = useProjects(organization.id)
40+
const { projects } = useProjects(organization.id)
3741
const [{ project }, setParams] = useQueryStates(projectParsers)
3842
/** The first project is the default so a fresh visit lands in a project, not an org-wide chat. */
3943
const selected =
@@ -66,7 +70,7 @@ export function ProjectHome({ userName }: ProjectHomeProps) {
6670
onBeforeSend={(message, mode) => {
6771
if (mode !== 'agent' || !selected) return false
6872
router.push(
69-
`${organizationRoutes(organization.id).project(selected.id)}?chat=new&q=${encodeURIComponent(message)}`
73+
`${organizationRoutes(organization.id).project(defaultEnvironment(selected).workspaceId)}?chat=new&q=${encodeURIComponent(message)}`
7074
)
7175
return true
7276
}}

‎apps/sim/app/o/[organizationId]/layout.tsx‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ export default async function OrganizationLayout({
9292
dashboards: dashboardsEnabled,
9393
'workflow-tests': workflowTestsEnabled,
9494
changelog: changelogEnabled,
95-
'org-project-view': orgProjectViewEnabled,
9695
'mothership-model-selector': modelSelectorEnabled,
9796
'mothership-plan-mode': planModeEnabled,
9897
'org-project-view': orgProjectViewEnabled,

‎apps/sim/app/o/[organizationId]/p/components/dashboard-file.tsx‎

Lines changed: 0 additions & 30 deletions
This file was deleted.

‎apps/sim/app/o/[organizationId]/p/components/environment-switcher.tsx‎

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,33 +10,30 @@ import {
1010
import { Check, ChevronDown, Server } from '@sim/emcn/icons'
1111
import { useRouter } from 'next/navigation'
1212
import { organizationRoutes } from '@/lib/navigation/paths'
13-
import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects'
13+
import type { ProjectScope } from '@/app/o/[organizationId]/p/hooks/use-projects'
1414
import type { ProjectSection } from '@/app/o/[organizationId]/p/routes'
1515
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
1616

1717
interface EnvironmentSwitcherProps {
18-
project: Project
18+
project: ProjectScope
1919
section: ProjectSection
2020
}
2121

22-
/**
23-
* The project's environment (Prod, Staging, Sandbox): each is a forked workspace in the same
24-
* lineage, and switching keeps the section you are looking at.
25-
*/
22+
/** The project's environment: each is a workspace of the project, and switching keeps the section. */
2623
export function EnvironmentSwitcher({ project, section }: EnvironmentSwitcherProps) {
2724
const router = useRouter()
2825
const { organization } = useOrganizationContext()
2926
if (project.environments.length <= 1)
3027
return (
3128
<Chip leftIcon={Server} className='text-[var(--text-muted)]'>
32-
{project.environment}
29+
{project.environment.name}
3330
</Chip>
3431
)
3532
return (
3633
<DropdownMenu>
3734
<DropdownMenuTrigger asChild>
3835
<Chip leftIcon={Server} rightIcon={ChevronDown} aria-label='Switch environment'>
39-
{project.environment}
36+
{project.environment.name}
4037
</Chip>
4138
</DropdownMenuTrigger>
4239
<DropdownMenuContent align='end'>
@@ -49,8 +46,8 @@ export function EnvironmentSwitcher({ project, section }: EnvironmentSwitcherPro
4946
)
5047
}
5148
>
52-
<span className='flex-1'>{environment.label}</span>
53-
{environment.workspaceId === project.id && (
49+
<span className='flex-1'>{environment.name}</span>
50+
{environment.workspaceId === project.workspaceId && (
5451
<Check className='size-[14px] text-[var(--text-icon)]' />
5552
)}
5653
</DropdownMenuItem>

0 commit comments

Comments
 (0)