diff --git a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx index 6e4089eea17..c91cb5b72bc 100644 --- a/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/composer/composer.test.tsx @@ -239,6 +239,7 @@ async function render( 'table-row-ttl': false, 'mothership-model-selector': mocks.advanced, 'mothership-plan-mode': mocks.plan, + 'org-project-view': false, }} > @@ -309,6 +310,7 @@ it('keeps restored queued skills scoped when replacing a draft', async () => { 'table-row-ttl': false, 'mothership-model-selector': mocks.advanced, 'mothership-plan-mode': mocks.plan, + 'org-project-view': false, }} > diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index 7baabe357df..3946c9c9d8d 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' +import { type ReactNode, useCallback, useEffect, useState, useSyncExternalStore } from 'react' import { toast } from '@sim/emcn' import { useQueryClient } from '@tanstack/react-query' import Link from 'next/link' @@ -52,10 +52,26 @@ import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store' import { useOrganizationChatModeStore } from '@/stores/organization-chat-mode/store' import type { ChatContext } from '@/stores/panel' +/** What a host surface adds to the empty Home for one mode. */ +interface OrganizationHomeLanding { + heading?: string + /** Rendered above the composer, such as a picker that scopes the next message. */ + accessory?: ReactNode +} + interface OrganizationHomeProps { userName?: string chatId?: string requestMode?: ChatRequestMode + /** Lets a host surface change the empty Home's heading and add a control, per mode. */ + landing?: (requestMode: ChatRequestMode) => OrganizationHomeLanding | undefined + /** + * Called with the first message of a new chat before it is sent here; returning true means + * the host sent it elsewhere, so this Home clears the draft and sends nothing. + */ + onBeforeSend?: (message: string, requestMode: ChatRequestMode) => boolean + /** False keeps the address bar where it is when the chat gets its id. */ + syncChatUrl?: boolean } const subscribeToClient = () => () => {} @@ -82,6 +98,9 @@ function OrganizationHomeContent({ userName, chatId, requestMode: savedMode, + landing, + onBeforeSend, + syncChatUrl, }: OrganizationHomeProps) { const { organization, searchAccess, canBuild, mothershipAvailable } = useOrganizationContext() const { data: session } = useSession() @@ -114,6 +133,7 @@ function OrganizationHomeContent({ requestMode, onResourceEvent: controller.onResourceEvent, activeResourceState: controller.activeResourceState, + ...(syncChatUrl === false ? { syncChatUrl } : {}), }) ) const initialDraftKey = `${userId}:organization:${organization.id}:${chatId ?? 'new'}` @@ -296,6 +316,11 @@ function OrganizationHomeContent({ path: file.path, })) if (!message && !attachments.length) return + if (!hasChat && message && onBeforeSend?.(message, requestMode)) { + useMothershipDraftsStore.getState().clearDraft(draftKey) + files.clearAttachedFiles() + return + } useMothershipDraftsStore.getState().clearDraft(draftKey) send(message, attachments.length ? attachments : undefined, contexts) setRestoredContexts([]) @@ -334,6 +359,7 @@ function OrganizationHomeContent({ /> ) + const hostLanding = hasChat ? undefined : landing?.(requestMode) const content = (
{hasChat ? ( @@ -399,13 +425,15 @@ function OrganizationHomeContent({ ) : ( + {hostLanding?.accessory} {composer}
{requestMode === 'agent' ? ( diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 069d1d1b14f..cebd2a1c4b1 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -7,6 +7,7 @@ import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { isOrgProjectViewEnabled } from '@/lib/projects/feature-flag' import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' @@ -55,19 +56,26 @@ export default async function OrganizationLayout({ if (!context.mothershipAvailable && !context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) - const [, tableRowTtlEnabled, modelSelectorEnabled, planModeEnabled, dashboardsEnabled] = - await Promise.all([ - prefetchOrganizationSidebar( - queryClient, - organizationId, - { kind: 'session', userId: session.user.id, sessionId: session.session.id }, - getActiveOrganizationId(session) - ), - isTableRowTtlEnabled(), - isMothershipModelSelectorEnabled(), - isPlanModeEnabled(), - isDashboardsEnabled(organizationId), - ]) + const [ + , + tableRowTtlEnabled, + modelSelectorEnabled, + planModeEnabled, + dashboardsEnabled, + orgProjectViewEnabled, + ] = await Promise.all([ + prefetchOrganizationSidebar( + queryClient, + organizationId, + { kind: 'session', userId: session.user.id, sessionId: session.session.id }, + getActiveOrganizationId(session) + ), + isTableRowTtlEnabled(), + isMothershipModelSelectorEnabled(), + isPlanModeEnabled(), + isDashboardsEnabled(organizationId), + isOrgProjectViewEnabled(organizationId), + ]) const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' return ( @@ -78,6 +86,7 @@ export default async function OrganizationLayout({ 'table-row-ttl': tableRowTtlEnabled, 'mothership-model-selector': modelSelectorEnabled, 'mothership-plan-mode': planModeEnabled, + 'org-project-view': orgProjectViewEnabled, }} > diff --git a/apps/sim/app/playground/org/[[...slug]]/page.tsx b/apps/sim/app/playground/org/[[...slug]]/page.tsx index a24a1d86368..00e29cd3fcf 100644 --- a/apps/sim/app/playground/org/[[...slug]]/page.tsx +++ b/apps/sim/app/playground/org/[[...slug]]/page.tsx @@ -1,17 +1,36 @@ import { Suspense } from 'react' +import { getSession } from '@/lib/auth' +import { getActiveOrganizationId } from '@/lib/auth/session-response' +import { + getOrganizationSurfaceContext, + type OrganizationSurfaceContext, + resolveOrganizationLanding, +} from '@/lib/organizations/surface' import { ProtoPage } from '@/app/playground/org/components/proto-page' +/** The organization the home page chats in, resolved the way the app entry picks one. */ +async function resolveHomeOrganization(): Promise { + const session = await getSession() + if (!session?.user) return null + const organizationId = await resolveOrganizationLanding( + session.user.id, + getActiveOrganizationId(session) + ) + return organizationId ? getOrganizationSurfaceContext(organizationId, session.user.id) : null +} + export default async function OrgPrototypePage({ params, }: { params: Promise<{ slug?: string[] }> }) { const { slug } = await params + const organization = slug?.length ? null : await resolveHomeOrganization() return ( Loading prototype…

} > - +
) } diff --git a/apps/sim/app/playground/org/components/agent-run.tsx b/apps/sim/app/playground/org/components/agent-run.tsx index 3dccf318f60..7575b74c6e9 100644 --- a/apps/sim/app/playground/org/components/agent-run.tsx +++ b/apps/sim/app/playground/org/components/agent-run.tsx @@ -6,8 +6,9 @@ import { CircleCheck, Mic, Plus } from '@sim/emcn/icons' import Link from 'next/link' import { ShimmerText } from '@/components/ui/shimmer-text' import { LinkedTickets } from '@/app/playground/org/components/linked-tickets' -import type { RunningWork } from '@/app/playground/org/lib/changelog-data' +import type { Project } from '@/app/playground/org/lib/project' import { protoRoutes } from '@/app/playground/org/lib/routes' +import type { RunningWork } from '@/app/playground/org/lib/types' const NARRATION_INTERVAL_S = 7 @@ -19,14 +20,14 @@ function formatElapsed(total: number): string { interface AgentRunProps { work: RunningWork - workspaceId: string + project: Project } /** * A background run seen from inside its chat: elapsed time, what the agent is saying now, * a Thinking shimmer, and a composer to steer, auto-approve, or stop it. */ -export function AgentRun({ work, workspaceId }: AgentRunProps) { +export function AgentRun({ work, project }: AgentRunProps) { const [seconds, setSeconds] = useState(0) const [autoApprove, setAutoApprove] = useState(false) const [stopped, setStopped] = useState(false) @@ -112,15 +113,15 @@ export function AgentRun({ work, workspaceId }: AgentRunProps) { {work.issue} - + diff --git a/apps/sim/app/playground/org/components/changelog.tsx b/apps/sim/app/playground/org/components/changelog.tsx index 35fc176d62c..578331652c4 100644 --- a/apps/sim/app/playground/org/components/changelog.tsx +++ b/apps/sim/app/playground/org/components/changelog.tsx @@ -6,17 +6,18 @@ import { CircleAlert, CircleCheck, Rocket } from '@sim/emcn/icons' import Link from 'next/link' import { EChartsView } from '@/components/charts/echarts-view' import { SlackMonoIcon } from '@/components/icons' +import { DEPTH_LABELS } from '@/lib/projects' import { RunningDot } from '@/app/playground/org/components/glyphs' -import { - CHANGELOG, - type ChangelogChart, - type ChangelogEntry, - type ChangelogMetric, - DRAFTS, - type DraftRelease, -} from '@/app/playground/org/lib/changelog-data' -import type { Workspace } from '@/app/playground/org/lib/mock-data' +import type { Project } from '@/app/playground/org/lib/project' +import { useProjectSources } from '@/app/playground/org/lib/project-sources' import { protoRoutes } from '@/app/playground/org/lib/routes' +import type { + ChangelogChart, + ChangelogEntry, + ChangelogMetric, + DraftRelease, + RunningWork, +} from '@/app/playground/org/lib/types' const RELEASE_YEAR = 2026 const TODAY = 'Sep 28' @@ -25,14 +26,30 @@ const AFTER_COLOR = '#5C7399' const H2 = '@min-[1000px]/dashboard:text-[24px] text-[20px] text-[var(--text-primary)] leading-tight tracking-[-0.02em]' -/** Next release on top (what's ready, what's still running), shipped releases below. */ -export function Changelog({ workspace }: { workspace: Workspace }) { - const draft = DRAFTS.find((d) => d.workspaceId === workspace.id) +/** + * What an environment's changelog shows: Prod what shipped, Staging the next release being + * drafted, Sandbox the work still running. Any other environment reads like Prod. + */ +type ChangelogMode = 'shipped' | 'draft' | 'running' + +function changelogMode(environment: string): ChangelogMode { + if (environment === DEPTH_LABELS[1]) return 'draft' + if (environment === DEPTH_LABELS[2]) return 'running' + return 'shipped' +} + +const MODE_COPY: Record string> = { + shipped: (name) => `What already changed in ${name}, release by release.`, + draft: (name) => `What’s about to ship in ${name}: the next release Sim has drafted.`, + running: (name) => + `What Sim is still working on in ${name}. Each joins the next release when it finishes.`, +} + +/** One environment's changelog: shipped releases, the draft next release, or the running work. */ +export function Changelog({ project }: { project: Project }) { + const mode = changelogMode(project.environment) + const { shipped, draft } = useProjectSources().changelogFor(project) const [released, setReleased] = useState(null) - const shipped = [ - ...(released ? [released] : []), - ...CHANGELOG.filter((entry) => entry.workspaceId === workspace.id), - ] const release = (draftRelease: DraftRelease) => { const ready = draftRelease.changes.filter((change) => change.state === 'ready') @@ -66,39 +83,59 @@ export function Changelog({ workspace }: { workspace: Workspace }) { Changelog

- What’s about to ship in {workspace.name}, what Sim is still working on, and what - already changed. + {MODE_COPY[mode](project.name)}

- {draft && ( - release(draft)} - /> - )} + {mode === 'shipped' && + (shipped.length > 0 ? ( + shipped.map((entry) => ) + ) : ( + Nothing has shipped here yet. + ))} - {shipped.map((entry) => ( - - ))} + {mode === 'draft' && + (draft ? ( + <> + release(draft)} + /> + {released && } + + ) : ( + No release is being drafted. + ))} - {!draft && shipped.length === 0 && ( -

Nothing has shipped here yet.

- )} + {mode === 'running' && + (draft && draft.running.length > 0 ? ( +
+ +
+ ) : ( + Nothing is running right now. + ))}
) } +function EmptyNote({ children }: { children: string }) { + return

{children}

+} + interface NextReleaseProps { draft: DraftRelease + projectId: string released: boolean onRelease: () => void } -function NextRelease({ draft, released, onRelease }: NextReleaseProps) { +/** The draft next release: what is ready, what is held for approval, and the chart it will mark. */ +function NextRelease({ draft, projectId, released, onRelease }: NextReleaseProps) { const changes = draft.changes.filter((change) => !released || change.state === 'approval') const readyCount = released ? 0 : draft.changes.filter((c) => c.state === 'ready').length const held = draft.changes.filter((change) => change.state === 'approval') @@ -133,35 +170,45 @@ function NextRelease({ draft, released, onRelease }: NextReleaseProps) { ) : ( )} -
- -
    - {draft.running.map((work) => ( -
  • - - - {work.task} - {work.step} - -
  • - ))} -
-
- ) } +interface RunningNowProps { + running: RunningWork[] + projectId: string +} + +/** The work Sim is running now; each row opens its issue. */ +function RunningNow({ running, projectId }: RunningNowProps) { + return ( + <> + +
    + {running.map((work) => ( +
  • + + + {work.task} + {work.step} + +
  • + ))} +
+ + ) +} + function SubHeading({ title, aside }: { title: string; aside?: string }) { return (
@@ -198,7 +245,7 @@ function DraftChangeRow({ change, workspaceId }: DraftChangeRowProps) { ) } -function Release({ entry }: { entry: ChangelogEntry }) { +function Release({ entry, projectId }: { entry: ChangelogEntry; projectId: string }) { const fresh = entry.id === 'released-now' return (
@@ -227,7 +274,7 @@ function Release({ entry }: { entry: ChangelogEntry }) {
  • {change.text} diff --git a/apps/sim/app/playground/org/components/dashboard-file.tsx b/apps/sim/app/playground/org/components/dashboard-file.tsx new file mode 100644 index 00000000000..8afbcf1f534 --- /dev/null +++ b/apps/sim/app/playground/org/components/dashboard-file.tsx @@ -0,0 +1,30 @@ +'use client' + +import type { ReactNode } from 'react' +import { DashboardPreview } from '@/components/dashboards/dashboard-preview' +import type { ProjectResources } from '@/app/playground/org/lib/use-project-resources' +import { useWorkspaceFileContent } from '@/hooks/queries/workspace-files' + +interface DashboardFileProps { + workspaceId: string + file: ProjectResources['dashboardFiles'][number] + title: ReactNode +} + +/** A real `.dashboard` workspace file, rendered live against the workspace's tables. */ +export function DashboardFile({ workspaceId, file, title }: DashboardFileProps) { + const { + data: content, + isLoading, + error, + } = useWorkspaceFileContent(workspaceId, file.id, file.key) + if (error) + return ( +

    {error.message}

    + ) + if (isLoading || content === undefined) + return

    Loading…

    + return ( + + ) +} diff --git a/apps/sim/app/playground/org/components/delegate-avatar.tsx b/apps/sim/app/playground/org/components/delegate-avatar.tsx index 14ac9eff9f2..9617f26d442 100644 --- a/apps/sim/app/playground/org/components/delegate-avatar.tsx +++ b/apps/sim/app/playground/org/components/delegate-avatar.tsx @@ -1,6 +1,6 @@ import { Avatar, cn } from '@sim/emcn' import { Sim, Workflow, Zap } from '@sim/emcn/icons' -import type { Delegate, Person } from '@/app/playground/org/lib/mock-data' +import type { Delegate, Person } from '@/app/playground/org/lib/types' const DELEGATE_ICON = { agent: Zap, workflow: Workflow, sim: Sim } as const diff --git a/apps/sim/app/playground/org/components/environment-switcher.tsx b/apps/sim/app/playground/org/components/environment-switcher.tsx new file mode 100644 index 00000000000..12f706abdd8 --- /dev/null +++ b/apps/sim/app/playground/org/components/environment-switcher.tsx @@ -0,0 +1,54 @@ +'use client' + +import { + Chip, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@sim/emcn' +import { Check, ChevronDown, Server } from '@sim/emcn/icons' +import { useRouter } from 'next/navigation' +import type { Project } from '@/app/playground/org/lib/project' +import { protoRoutes, type WorkspaceSection } from '@/app/playground/org/lib/routes' + +interface EnvironmentSwitcherProps { + project: Project + section: WorkspaceSection +} + +/** + * The project's environment (Prod, Staging, Sandbox): each is a forked workspace in the same + * lineage, and switching keeps the section you are looking at. + */ +export function EnvironmentSwitcher({ project, section }: EnvironmentSwitcherProps) { + const router = useRouter() + if (project.environments.length <= 1) + return ( + + {project.environment} + + ) + return ( + + + + {project.environment} + + + + {project.environments.map((environment) => ( + router.push(protoRoutes.workspace(environment.workspaceId, section))} + > + {environment.label} + {environment.workspaceId === project.id && ( + + )} + + ))} + + + ) +} diff --git a/apps/sim/app/playground/org/components/environments/environments-tab.tsx b/apps/sim/app/playground/org/components/environments/environments-tab.tsx new file mode 100644 index 00000000000..f3adc69f158 --- /dev/null +++ b/apps/sim/app/playground/org/components/environments/environments-tab.tsx @@ -0,0 +1,137 @@ +'use client' + +import { Skeleton } from '@sim/emcn' +import { useQueryState } from 'nuqs' +import { LineageStrip } from '@/app/playground/org/components/environments/lineage-strip' +import { + EnvironmentTable, + MappingGrid, + SkeletonRows, + WorkflowGrid, +} from '@/app/playground/org/components/environments/mapping-grid' +import { + lineageEdges, + orderEnvironments, + RESOURCE_TABS, + type ResourceTabId, + WORKFLOW_TAB, +} from '@/app/playground/org/components/environments/mapping-model' +import { + MOCK_MAPPING_ROWS, + MOCK_WORKFLOW_ROWS, + mockEnvironments, +} from '@/app/playground/org/components/environments/mock-mappings' +import { ResourceTabs } from '@/app/playground/org/components/environments/resource-tabs' +import { useEnvironmentMappings } from '@/app/playground/org/components/environments/use-environment-mappings' +import { type Project, realWorkspaceId } from '@/app/playground/org/lib/project' +import { protoParsers } from '@/app/playground/org/lib/search-params' +import { useOptionalWorkspacePermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { useForkingAvailability } from '@/ee/workspace-forking/hooks/use-forking-available' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' + +interface EnvironmentsTabProps { + project: Project +} + +/** + * The project's fork lineage and the resource mappings between its environments: one row per + * resource, one column per environment, a sub-tab per resource type. Managing the lineage + * needs workspace admin and the fork entitlement; otherwise the strip is read-only. + */ +export function EnvironmentsTab({ project }: EnvironmentsTabProps) { + const [resource, setResource] = useQueryState( + 'resource', + protoParsers.resource.withOptions({ history: 'replace', clearOnDefault: true }) + ) + const realId = realWorkspaceId(project) + const { data: workspaces } = useWorkspacesQuery(!project.isMock) + const availability = useForkingAvailability(realId || undefined) + const permissions = useOptionalWorkspacePermissionsContext()?.userPermissions + const canAdmin = permissions?.canAdmin ?? false + const gateLoading = + !project.isMock && (availability.isLoading || (permissions?.isLoading ?? false)) + const canManage = !project.isMock && availability.available && canAdmin + + /** A handful of workspaces, so the lookups are rebuilt each render rather than memoized. */ + const parentOf = new Map() + const nameOf = new Map() + for (const workspace of workspaces ?? []) { + parentOf.set(workspace.id, workspace.forkedFromWorkspaceId ?? null) + nameOf.set(workspace.id, workspace.name) + } + const columns = project.isMock + ? mockEnvironments(project.name) + : orderEnvironments(project.rootId, project.environments, parentOf, nameOf) + const edges = lineageEdges(columns) + const data = useEnvironmentMappings({ + columns, + edges, + forksEnabled: canManage, + workflowsEnabled: !project.isMock, + }) + const rows = project.isMock ? MOCK_MAPPING_ROWS : data.rows + const workflowRows = project.isMock ? MOCK_WORKFLOW_ROWS : data.workflowRows + + const counts = new Map([[WORKFLOW_TAB, workflowRows.length]]) + for (const row of rows) counts.set(row.kind, (counts.get(row.kind) ?? 0) + 1) + const tab = RESOURCE_TABS.find((candidate) => candidate.id === resource) ?? RESOURCE_TABS[0] + + const notice = project.isMock + ? 'Example mappings: this project has no workspace yet.' + : gateLoading + ? null + : !availability.available + ? 'Forking is not available for this workspace.' + : !canAdmin + ? 'Only workspace admins can manage environments.' + : edges.length === 0 + ? 'This project has one environment. Create a fork to map resources between environments.' + : null + const showGrid = project.isMock || (canManage && edges.length > 0) + + return ( +
    + + {gateLoading ? : null} + {notice ?

    {notice}

    : null} + {showGrid ? ( +
    + void setResource(id)} /> + {data.errors.map(({ edge, message }) => ( +

    + Couldn't load the mapping between {nameOf.get(edge.parentId) ?? edge.parentId} and{' '} + {nameOf.get(edge.childId) ?? edge.childId}: {message} +

    + ))} + {data.isPending ? ( + + + + ) : tab.id === WORKFLOW_TAB ? ( + + ) : ( + row.kind === tab.id)} + canEdit={canManage} + credentialsByEnv={data.credentialsByEnv} + empty={`No ${tab.label} referenced by deployed workflows.`} + /> + )} +
    + ) : null} +
    + ) +} diff --git a/apps/sim/app/playground/org/components/environments/index.ts b/apps/sim/app/playground/org/components/environments/index.ts new file mode 100644 index 00000000000..a528794d847 --- /dev/null +++ b/apps/sim/app/playground/org/components/environments/index.ts @@ -0,0 +1,42 @@ +export { EnvironmentsTab } from '@/app/playground/org/components/environments/environments-tab' +export { LineageStrip } from '@/app/playground/org/components/environments/lineage-strip' +export { + EnvironmentTable, + MappingGrid, + SkeletonRows, + StatusBadge, + WorkflowGrid, +} from '@/app/playground/org/components/environments/mapping-grid' +export { + buildMappingRows, + buildWorkflowRows, + type EdgeDiff, + type EdgeMapping, + type EnvironmentColumn, + entryStatus, + isCopyableKind, + type LineageEdge, + lineageEdges, + MAPPING_STATUS, + type MappingCell, + type MappingRow, + type MappingStatus, + orderEnvironments, + RESOURCE_TAB_IDS, + RESOURCE_TABS, + type ResourceTab, + type ResourceTabId, + WORKFLOW_TAB, + type WorkflowCell, + type WorkflowRow, +} from '@/app/playground/org/components/environments/mapping-model' +export { + MOCK_MAPPING_ROWS, + MOCK_WORKFLOW_ROWS, + mockEnvironments, +} from '@/app/playground/org/components/environments/mock-mappings' +export { ResourceTabs } from '@/app/playground/org/components/environments/resource-tabs' +export { + type EdgeError, + useEnvironmentMappings, +} from '@/app/playground/org/components/environments/use-environment-mappings' diff --git a/apps/sim/app/playground/org/components/environments/lineage-strip.tsx b/apps/sim/app/playground/org/components/environments/lineage-strip.tsx new file mode 100644 index 00000000000..436e98d0d6e --- /dev/null +++ b/apps/sim/app/playground/org/components/environments/lineage-strip.tsx @@ -0,0 +1,254 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipConfirmModal, + ChipLink, + ChipTag, + cn, + OverflowText, + Tooltip, + toast, +} from '@sim/emcn' +import { ArrowRight, ArrowUpRight, Plus, RefreshCw, Server, TriangleAlert } from '@sim/emcn/icons' +import { getErrorMessage } from '@sim/utils/errors' +import { getWorkspaceSettingsHref } from '@/components/settings/navigation' +import type { GetForkLineageResponse } from '@/lib/api/contracts/workspace-fork' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import type { EnvironmentColumn } from '@/app/playground/org/components/environments/mapping-model' +import type { Project } from '@/app/playground/org/lib/project' +import { workspaceRoutes } from '@/app/playground/org/lib/routes' +import { + forkIdParam, + forkSyncDirectionParam, +} from '@/app/workspace/[workspaceId]/settings/[section]/search-params' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { ForkWorkspaceModal } from '@/ee/workspace-forking/components/fork-workspace-modal/fork-workspace-modal' +import { useUnlinkFork } from '@/ee/workspace-forking/hooks/workspace-fork' +import { useWorkspaceCreationPolicy } from '@/hooks/queries/workspace' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' + +/** Explains a disabled Sync whose parent workspace the viewer cannot open. */ +const NO_ACCESS_TOOLTIP = "You don't have access to the parent workspace" + +/** The fork's sync page for its parent edge, pulling the parent's changes in. */ +function syncHref(column: EnvironmentColumn, parentId: string): string { + return getWorkspaceSettingsHref( + column.id, + 'forks', + new URLSearchParams({ [forkIdParam.key]: parentId, [forkSyncDirectionParam.key]: 'pull' }) + ) +} + +interface UnlinkTarget { + column: EnvironmentColumn + parent: EnvironmentColumn +} + +interface EnvironmentCardProps { + column: EnvironmentColumn + parent?: EnvironmentColumn + current: boolean + lineage?: GetForkLineageResponse + canManage: boolean + /** A mock project's environments are not workspaces, so nothing links anywhere. */ + isMock: boolean + onDisconnect: () => void +} + +function EnvironmentCard({ + column, + parent, + current, + lineage, + canManage, + isMock, + onDisconnect, +}: EnvironmentCardProps) { + const parentAccessible = lineage?.parent?.viewerAccessible ?? true + const undoable = lineage?.undoableRun ?? null + const showSync = canManage && parent !== undefined + return ( +
    +
    + + {column.label} + {current ? Current : null} + {canManage && parent ? ( + + ) : null} +
    + +

    + {parent ? `Forked from ${parent.label}` : 'Root of the lineage'} + {undoable ? ` · last sync from ${undoable.otherName} can be undone` : ''} +

    + {isMock ? null : ( +
    + + Open workspace + + {showSync && parent && parentAccessible ? ( + + Sync + + ) : null} + {showSync && !parentAccessible ? ( + + + + + Sync + + + + {NO_ACCESS_TOOLTIP} + + ) : null} +
    + )} +
    + ) +} + +interface LineageStripProps { + project: Project + /** Root first, each fork after its parent. */ + columns: readonly EnvironmentColumn[] + /** Each environment's lineage node, once loaded; empty until the fork gate passes. */ + lineageByEnv: ReadonlyMap + /** Admin with forking available: Create fork, Sync and Disconnect are offered. */ + canManage: boolean +} + +/** + * One card per environment of the project, in lineage order. Create fork opens the real fork + * modal on the current environment; a fork's card links to its parent edge's sync page and + * can disconnect from its parent. + */ +export function LineageStrip({ project, columns, lineageByEnv, canManage }: LineageStripProps) { + const { billingEnabled } = useDeploymentShape() + const { data: creationPolicy } = useWorkspaceCreationPolicy(!project.isMock) + const { navigateToSettings } = useSettingsNavigation() + const unlink = useUnlinkFork() + const [forkModalOpen, setForkModalOpen] = useState(false) + const [confirmUnlink, setConfirmUnlink] = useState(null) + + const byId = new Map(columns.map((column) => [column.id, column])) + const current = byId.get(project.id) + + /** Permanently dissolves the confirmed edge; both workspaces remain. */ + const runUnlink = async () => { + if (!confirmUnlink) return + try { + await unlink.mutateAsync({ + workspaceId: confirmUnlink.column.id, + body: { otherWorkspaceId: confirmUnlink.parent.id }, + }) + toast.success(`Disconnected ${confirmUnlink.column.label} from ${confirmUnlink.parent.label}`) + setConfirmUnlink(null) + } catch (error) { + toast.error(getErrorMessage(error, 'Disconnect failed')) + } + } + + return ( +
    +
    +

    Environments

    + {canManage ? ( + setForkModalOpen(true)}> + Create fork + + ) : null} +
    +
      + {columns.map((column, index) => { + const parent = column.parentId ? byId.get(column.parentId) : undefined + /** The arrow reads "forked from" only when the parent is the card before this one. */ + const chained = index > 0 && parent?.id === columns[index - 1].id + return ( +
    1. + {index > 0 ? ( + + ) : null} + { + if (parent) setConfirmUnlink({ column, parent }) + }} + /> +
    2. + ) + })} +
    + + {project.isMock ? null : ( + ({ id: column.id, name: column.name }))} + canFork={creationPolicy?.canCreate ?? true} + onUpgrade={() => { + if (billingEnabled) navigateToSettings({ section: 'billing' }) + }} + /> + )} + + { + if (!open) setConfirmUnlink(null) + }} + srTitle='Disconnect fork' + title='Disconnect fork' + text={[ + 'This permanently removes the fork relationship between ', + { text: confirmUnlink?.column.label ?? '', bold: true }, + ' and ', + { text: confirmUnlink?.parent.label ?? '', bold: true }, + ". Both workspaces stay exactly as they are, but they will no longer appear in each other's fork lists, and syncing between them stops.", + ]} + confirm={{ + label: 'Disconnect', + onClick: () => void runUnlink(), + pending: unlink.isPending, + pendingLabel: 'Disconnecting...', + }} + > +
    + + + This cannot be undone: the saved mappings and sync history for this pair are deleted, + and forking again creates a brand-new workspace. + +
    +
    +
    + ) +} diff --git a/apps/sim/app/playground/org/components/environments/mapping-grid.tsx b/apps/sim/app/playground/org/components/environments/mapping-grid.tsx new file mode 100644 index 00000000000..ea2627a176c --- /dev/null +++ b/apps/sim/app/playground/org/components/environments/mapping-grid.tsx @@ -0,0 +1,313 @@ +'use client' + +import type { ReactNode } from 'react' +import { Badge, ChipCombobox, OverflowText, Skeleton, toast } from '@sim/emcn' +import { getErrorMessage } from '@sim/utils/errors' +import type { WorkspaceCredential } from '@/lib/api/contracts/credentials' +import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' +import { + type EnvironmentColumn, + isCopyableKind, + leafResourceId, + MAPPING_STATUS, + type MappingCell, + type MappingRow, + type MappingStatus, + type WorkflowCell, + type WorkflowRow, +} from '@/app/playground/org/components/environments/mapping-model' +import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { useUpdateForkMapping } from '@/ee/workspace-forking/hooks/workspace-fork' + +/** Option that clears the target so a sync copies the resource instead; handled via onSelect, never sent. */ +const NEW_COPY_VALUE = '__new_copy__' + +const SKELETON_ROW_COUNT = 3 + +interface StatusBadgeProps { + status: MappingStatus +} + +/** One cell's mapping status in the sync view's vocabulary and colors. */ +export function StatusBadge({ status }: StatusBadgeProps) { + const { label, variant } = MAPPING_STATUS[status] + return ( + + {label} + + ) +} + +interface EnvironmentTableProps { + columns: readonly EnvironmentColumn[] + children: ReactNode +} + +/** The grid frame: a resource column, then one column per environment. */ +export function EnvironmentTable({ columns, children }: EnvironmentTableProps) { + return ( +
    + + + + {columns.map((column) => ( + + ))} + + + + + {columns.map((column) => ( + + ))} + + + {children} +
    + Resource + +
    + {column.label} + +
    +
    +
    + ) +} + +interface EmptyRowProps { + columns: readonly EnvironmentColumn[] + children: ReactNode +} + +function EmptyRow({ columns, children }: EmptyRowProps) { + return ( + + + {children} + + + ) +} + +interface SkeletonRowsProps { + columns: readonly EnvironmentColumn[] +} + +/** Placeholder rows while the edges' mappings load. */ +export function SkeletonRows({ columns }: SkeletonRowsProps) { + return Array.from({ length: SKELETON_ROW_COUNT }, (_, index) => ( + + + + + {columns.map((column) => ( + + + + ))} + + )) +} + +function AbsentCell() { + return — +} + +interface MappingCellViewProps { + cell: MappingCell + /** The viewer may change this edge's target; the cell renders the candidate picker. */ + editable: boolean + /** This cell's edit is in flight. */ + pending: boolean + onChange: (targetId: string | null) => void +} + +/** A resource on one environment's side: its name (or the target picker) and its status. */ +function MappingCellView({ cell, editable, pending, onChange }: MappingCellViewProps) { + const entry = cell.entry + if (!editable || !entry) { + return ( +
    + + {cell.status ? : null} +
    + ) + } + const copyable = isCopyableKind(entry.kind) && !entry.sourceDeleted + const targetListed = entry.candidates.some((candidate) => candidate.id === entry.targetId) + const options = [ + // A stored target past the capped candidate list still shows its name when closed. + ...(entry.targetId && !targetListed + ? [{ label: cell.label, value: entry.targetId, hidden: true }] + : []), + // The way back to the copy flow after mapping: clears the target via onSelect. + ...(copyable && entry.targetId + ? [{ label: 'New copy', value: NEW_COPY_VALUE, onSelect: () => onChange(null) }] + : []), + ...entry.candidates.map((candidate) => ({ label: candidate.label, value: candidate.id })), + ] + return ( +
    + onChange(value)} + placeholder={copyable ? 'Copy on sync' : 'Select target'} + searchable + searchPlaceholder='Search targets' + disabled={pending} + /> + {cell.status ? : null} +
    + ) +} + +interface MappingGridProps { + columns: readonly EnvironmentColumn[] + rows: readonly MappingRow[] + /** Admin with forking available: child cells become candidate pickers. */ + canEdit: boolean + empty: string + /** Credentials by environment id, for the provider mark on credential rows. */ + credentialsByEnv?: ReadonlyMap> +} + +interface ResourceLabelProps { + row: MappingRow + columns: readonly EnvironmentColumn[] + credentialsByEnv?: ReadonlyMap> +} + +/** The resource's name, led by its provider's mark for a credential the lineage still holds. */ +function ResourceLabel({ row, columns, credentialsByEnv }: ResourceLabelProps) { + const leaf = row.kind === 'credential' ? leafResourceId(row, columns) : null + const credential = leaf ? credentialsByEnv?.get(leaf.environmentId)?.get(leaf.id) : undefined + const display = credential ? resolveCredentialDisplay(credential) : null + return ( +
    + {display?.icon ? : null} + +
    + ) +} + +/** + * One row per resource, one cell per environment. A child cell's picker saves through the + * edge's mapping route at once, so the forks settings page of that fork shows the same target. + */ +export function MappingGrid({ columns, rows, canEdit, credentialsByEnv, empty }: MappingGridProps) { + const updateMapping = useUpdateForkMapping() + const pendingKey = + updateMapping.isPending && updateMapping.variables + ? `${updateMapping.variables.workspaceId}:${updateMapping.variables.body.entries[0]?.sourceId ?? ''}` + : null + + const saveTarget = (cell: MappingCell, targetId: string | null) => { + if (!cell.entry || !cell.edge) return + updateMapping.mutate( + { + workspaceId: cell.edge.childId, + body: { + otherWorkspaceId: cell.edge.parentId, + direction: 'pull', + entries: [ + { resourceType: cell.entry.resourceType, sourceId: cell.entry.sourceId, targetId }, + ], + }, + }, + { + onSuccess: () => toast.success('Mapping saved'), + onError: (error) => toast.error(getErrorMessage(error, 'Failed to save mapping')), + } + ) + } + + return ( + + {rows.length === 0 ? ( + {empty} + ) : ( + rows.map((row) => ( + + + + + {columns.map((column) => { + const cell = row.cells[column.id] + return ( + + {cell ? ( + saveTarget(cell, targetId)} + /> + ) : ( + + )} + + ) + })} + + )) + )} + + ) +} + +interface WorkflowCellViewProps { + cell: WorkflowCell +} + +function WorkflowCellView({ cell }: WorkflowCellViewProps) { + return ( +
    + +
    + {cell.status ? : null} + {cell.deployed !== null ? ( + + {cell.deployed ? 'Deployed' : 'Draft'} + + ) : null} +
    +
    + ) +} + +interface WorkflowGridProps { + columns: readonly EnvironmentColumn[] + rows: readonly WorkflowRow[] + empty: string +} + +/** One row per deployed workflow, with what a sync would do on each edge and each side's deployed state. */ +export function WorkflowGrid({ columns, rows, empty }: WorkflowGridProps) { + return ( + + {rows.length === 0 ? ( + {empty} + ) : ( + rows.map((row) => ( + + + + + {columns.map((column) => { + const cell = row.cells[column.id] + return ( + + {cell ? : } + + ) + })} + + )) + )} + + ) +} diff --git a/apps/sim/app/playground/org/components/environments/mapping-model.ts b/apps/sim/app/playground/org/components/environments/mapping-model.ts new file mode 100644 index 00000000000..5ee2b296553 --- /dev/null +++ b/apps/sim/app/playground/org/components/environments/mapping-model.ts @@ -0,0 +1,305 @@ +import type { BadgeProps } from '@sim/emcn' +import { + type ForkMappingEntry, + type ForkWorkflowChange, + forkCopyableKindSchema, +} from '@/lib/api/contracts/workspace-fork' +import type { ProjectEnvironment } from '@/lib/projects' +import { + MAPPING_SECTION, + type MappableMappingKind, +} from '@/ee/workspace-forking/components/fork-sync/use-fork-sync' + +/** The workflows sub-tab: fed by the sync diff rather than a mapping kind. */ +export const WORKFLOW_TAB = 'workflow' + +export type ResourceTabId = typeof WORKFLOW_TAB | MappableMappingKind + +export interface ResourceTab { + id: ResourceTabId + label: string +} + +/** Sub-tabs in the sync view's order: workflows first, then every mapping kind. */ +export const RESOURCE_TABS: readonly ResourceTab[] = [ + { id: WORKFLOW_TAB, label: 'Workflows' }, + ...(Object.keys(MAPPING_SECTION) as MappableMappingKind[]) + .sort((a, b) => MAPPING_SECTION[a].order - MAPPING_SECTION[b].order) + .map((id) => ({ id, label: MAPPING_SECTION[id].label })), +] + +/** The ids the `?resource=` URL param accepts. */ +export const RESOURCE_TAB_IDS: readonly ResourceTabId[] = RESOURCE_TABS.map((tab) => tab.id) + +/** The sync view's mapping vocabulary, one status per cell. */ +export type MappingStatus = 'mapped' | 'suggested' | 'copy' | 'needs-setup' | 'source-deleted' + +/** Badge copy and color per status, matching the sync view's kind badges. */ +export const MAPPING_STATUS: Record< + MappingStatus, + { label: string; variant: NonNullable } +> = { + mapped: { label: 'Mapped', variant: 'green' }, + suggested: { label: 'Suggested', variant: 'amber' }, + copy: { label: 'Copy', variant: 'gray-secondary' }, + 'needs-setup': { label: 'Needs setup', variant: 'amber' }, + 'source-deleted': { label: 'Source deleted', variant: 'red' }, +} + +/** Kinds a sync copies into the target when they are left unmapped. */ +const COPYABLE_KINDS: ReadonlySet = new Set(forkCopyableKindSchema.options) + +export function isCopyableKind(kind: string): boolean { + return COPYABLE_KINDS.has(kind) +} + +/** The status of the child side of an edge entry. */ +export function entryStatus(entry: ForkMappingEntry): MappingStatus { + if (entry.targetId) return entry.suggested ? 'suggested' : 'mapped' + if (isCopyableKind(entry.kind) && !entry.sourceDeleted) return 'copy' + return 'needs-setup' +} + +/** One direct parent to child fork edge; mappings and diffs exist per edge. */ +export interface LineageEdge { + childId: string + parentId: string +} + +/** One environment as a grid column and a lineage card. */ +export interface EnvironmentColumn { + id: string + label: string + name: string + /** The environment this one was forked from; null for the root. */ + parentId: string | null +} + +/** + * Orders the environments root first and each fork after its parent, so every edge's parent + * side is placed before its child side and rows can be joined edge by edge. + */ +export function orderEnvironments( + rootId: string, + environments: readonly ProjectEnvironment[], + parentOf: ReadonlyMap, + nameOf: ReadonlyMap +): EnvironmentColumn[] { + const columns: EnvironmentColumn[] = [] + const seen = new Set() + const queue = [rootId] + for (let index = 0; index < queue.length; index += 1) { + const current = queue[index] + if (seen.has(current)) continue + seen.add(current) + const environment = environments.find((candidate) => candidate.workspaceId === current) + if (!environment) continue + columns.push({ + id: current, + label: environment.label, + name: nameOf.get(current) ?? environment.label, + parentId: current === rootId ? null : (parentOf.get(current) ?? null), + }) + for (const candidate of environments) { + if (parentOf.get(candidate.workspaceId) === current) queue.push(candidate.workspaceId) + } + } + /** An environment whose parent left the lineage still gets a column, detached. */ + for (const environment of environments) { + if (seen.has(environment.workspaceId)) continue + columns.push({ + id: environment.workspaceId, + label: environment.label, + name: nameOf.get(environment.workspaceId) ?? environment.label, + parentId: null, + }) + } + return columns +} + +export function lineageEdges(columns: readonly EnvironmentColumn[]): LineageEdge[] { + const edges: LineageEdge[] = [] + for (const column of columns) { + if (column.parentId) edges.push({ childId: column.id, parentId: column.parentId }) + } + return edges +} + +/** One environment's side of a resource row. */ +export interface MappingCell { + /** The resource's id in this environment; null while the child side has no target yet. */ + id: string | null + label: string + /** Null on the origin side, where there is nothing to map. */ + status: MappingStatus | null + /** The edge entry this cell edits; present on the child side of an edge. */ + entry?: ForkMappingEntry + edge?: LineageEdge +} + +export interface MappingRow { + key: string + kind: MappableMappingKind + /** The root-most name of the resource. */ + label: string + /** By environment (workspace) id; an environment the resource never reached has no cell. */ + cells: Record +} + +export interface EdgeMapping { + edge: LineageEdge + entries: readonly ForkMappingEntry[] +} + +function targetLabel(entry: ForkMappingEntry): string { + if (!entry.targetId) return '' + return ( + entry.candidates.find((candidate) => candidate.id === entry.targetId)?.label ?? entry.targetId + ) +} + +/** + * Joins the per-edge mappings into one row per resource: an entry's source on the parent side + * joins the row that reached that resource through an earlier edge, else starts a row. Edges + * must arrive parent first (see {@link orderEnvironments}). + */ +export function buildMappingRows(edgeMappings: readonly EdgeMapping[]): MappingRow[] { + const rows: MappingRow[] = [] + /** Rows by `${workspaceId}:${kind}:${resourceId}` for every side they reached. */ + const byResource = new Map() + for (const { edge, entries } of edgeMappings) { + for (const entry of entries) { + if (entry.kind === 'knowledge-document') continue + const sourceKey = `${edge.parentId}:${entry.kind}:${entry.sourceId}` + let row = byResource.get(sourceKey) + if (!row) { + row = { + key: sourceKey, + kind: entry.kind, + label: entry.sourceLabel, + cells: { + [edge.parentId]: { + id: entry.sourceId, + label: entry.sourceLabel, + status: entry.sourceDeleted ? 'source-deleted' : null, + }, + }, + } + rows.push(row) + byResource.set(sourceKey, row) + } + row.cells[edge.childId] = { + id: entry.targetId, + label: targetLabel(entry), + status: entryStatus(entry), + entry, + edge, + } + if (entry.targetId) { + byResource.set(`${edge.childId}:${entry.kind}:${entry.targetId}`, row) + } + } + } + return rows.sort((a, b) => a.label.localeCompare(b.label)) +} + +/** + * The resource's id in the youngest environment that has it, so a row's icon follows the leaf + * credential (one Atlassian token serves Jira and Confluence; the fork's own pick decides). + */ +export function leafResourceId( + row: MappingRow, + columns: readonly EnvironmentColumn[] +): { environmentId: string; id: string } | null { + for (let index = columns.length - 1; index >= 0; index -= 1) { + const column = columns[index] + const id = row.cells[column.id]?.id + if (id) return { environmentId: column.id, id } + } + return null +} + +/** One environment's side of a workflow row. */ +export interface WorkflowCell { + /** Null when the workflow does not exist in this environment yet. */ + name: string | null + status: MappingStatus | null + /** Null when the environment's workflow list does not name it. */ + deployed: boolean | null +} + +export interface WorkflowRow { + key: string + label: string + cells: Record +} + +export interface EdgeDiff { + edge: LineageEdge + workflows: readonly ForkWorkflowChange[] +} + +/** + * Joins the per-edge sync previews into one row per deployed workflow, by name: a pull's + * `otherName` is the parent's workflow, `currentName` the child's. `update` means both sides + * have it, `create` that the child lacks it, `archive` that only the child has it. + */ +export function buildWorkflowRows( + edgeDiffs: readonly EdgeDiff[], + deployedByEnv: ReadonlyMap> +): WorkflowRow[] { + const rows: WorkflowRow[] = [] + const byName = new Map() + const deployed = (workspaceId: string, name: string): boolean | null => + deployedByEnv.get(workspaceId)?.get(name) ?? null + for (const { edge, workflows } of edgeDiffs) { + for (const change of workflows) { + if (change.action === 'archive') { + const key = `${edge.childId}:${change.currentName}` + if (byName.has(key)) continue + const row: WorkflowRow = { + key, + label: change.currentName, + cells: { + [edge.childId]: { + name: change.currentName, + status: 'source-deleted', + deployed: deployed(edge.childId, change.currentName), + }, + }, + } + rows.push(row) + byName.set(key, row) + continue + } + const sourceKey = `${edge.parentId}:${change.otherName}` + let row = byName.get(sourceKey) + if (!row) { + row = { + key: sourceKey, + label: change.otherName, + cells: { + [edge.parentId]: { + name: change.otherName, + status: null, + deployed: deployed(edge.parentId, change.otherName), + }, + }, + } + rows.push(row) + byName.set(sourceKey, row) + } + if (change.action === 'create') { + row.cells[edge.childId] = { name: null, status: 'copy', deployed: null } + continue + } + row.cells[edge.childId] = { + name: change.currentName, + status: 'mapped', + deployed: deployed(edge.childId, change.currentName), + } + byName.set(`${edge.childId}:${change.currentName}`, row) + } + } + return rows.sort((a, b) => a.label.localeCompare(b.label)) +} diff --git a/apps/sim/app/playground/org/components/environments/mock-mappings.ts b/apps/sim/app/playground/org/components/environments/mock-mappings.ts new file mode 100644 index 00000000000..74cdf633d5c --- /dev/null +++ b/apps/sim/app/playground/org/components/environments/mock-mappings.ts @@ -0,0 +1,87 @@ +import type { + EnvironmentColumn, + MappingRow, + MappingStatus, + WorkflowRow, +} from '@/app/playground/org/components/environments/mapping-model' + +const PROD = 'mock-prod' +const STAGING = 'mock-staging' +const SANDBOX = 'mock-sandbox' + +/** Three environments for a project that exists only as a pack; nothing here is a real workspace. */ +export function mockEnvironments(projectName: string): EnvironmentColumn[] { + return [ + { id: PROD, label: 'Prod', name: `${projectName} Prod`, parentId: null }, + { id: STAGING, label: 'Staging', name: `${projectName} Staging`, parentId: PROD }, + { id: SANDBOX, label: 'Sandbox', name: `${projectName} Sandbox`, parentId: STAGING }, + ] +} + +type MockCells = readonly [string, string | null, string | null] +type MockStatuses = readonly [MappingStatus | null, MappingStatus | null] + +function row( + kind: MappingRow['kind'], + labels: MockCells, + statuses: MockStatuses = ['mapped', 'mapped'] +): MappingRow { + const [prod, staging, sandbox] = labels + const cells: MappingRow['cells'] = { [PROD]: { id: prod, label: prod, status: null } } + if (staging !== null) cells[STAGING] = { id: staging, label: staging, status: statuses[0] } + if (sandbox !== null) cells[SANDBOX] = { id: sandbox, label: sandbox, status: statuses[1] } + return { key: `${kind}:${prod}`, kind, label: prod, cells } +} + +/** An agent product's resources as they map across its three environments. */ +export const MOCK_MAPPING_ROWS: readonly MappingRow[] = [ + row( + 'credential', + ['Slack (support bot)', 'Slack (staging bot)', 'Slack (sandbox bot)'], + ['mapped', 'suggested'] + ), + row('credential', ['Zendesk API', 'Zendesk sandbox', 'Zendesk sandbox']), + row('credential', ['Gmail (replies)', '', null], ['needs-setup', null]), + row('env-var', ['OPENAI_API_KEY', 'OPENAI_API_KEY', 'OPENAI_API_KEY']), + row('env-var', ['ESCALATION_CHANNEL', 'ESCALATION_CHANNEL', ''], ['mapped', 'needs-setup']), + row('table', ['Ticket queue', 'Ticket queue', 'Ticket queue']), + row('table', ['Reply templates', '', ''], ['copy', 'copy']), + row('knowledge-base', ['Help center articles', 'Help center articles', 'Help center articles']), + row('knowledge-base', ['Product FAQ', 'Product FAQ', ''], ['mapped', 'copy']), + row('file-folder', ['Templates', 'Templates', 'Templates']), + row('file', ['tone-guide.md', 'tone-guide.md', 'tone-guide.md']), + row('file', ['macros.csv', '', ''], ['copy', 'copy']), + row('mcp-server', ['Internal tools', 'Internal tools (staging)', ''], ['mapped', 'needs-setup']), + row('custom-tool', ['Lookup order', 'Lookup order', 'Lookup order']), + row('custom-block', ['Sentiment score', 'Sentiment score', ''], ['mapped', 'needs-setup']), + row('skill', ['Refund policy', '', ''], ['copy', 'copy']), + row('sandbox', ['Repro sandbox', '', null], ['needs-setup', null]), +] + +function workflow( + name: string, + deployed: readonly [boolean, boolean | null, boolean | null], + statuses: MockStatuses = ['mapped', 'mapped'] +): WorkflowRow { + const cells: WorkflowRow['cells'] = { [PROD]: { name, status: null, deployed: deployed[0] } } + if (statuses[0] !== null) + cells[STAGING] = { + name: statuses[0] === 'copy' ? null : name, + status: statuses[0], + deployed: deployed[1], + } + if (statuses[1] !== null) + cells[SANDBOX] = { + name: statuses[1] === 'copy' ? null : name, + status: statuses[1], + deployed: deployed[2], + } + return { key: `workflow:${name}`, label: name, cells } +} + +export const MOCK_WORKFLOW_ROWS: readonly WorkflowRow[] = [ + workflow('Triage inbound tickets', [true, true, true]), + workflow('Draft reply', [true, true, false]), + workflow('Escalate to on-call', [true, true, null], ['mapped', 'copy']), + workflow('Weekly quality digest', [true, null, null], ['copy', 'copy']), +] diff --git a/apps/sim/app/playground/org/components/environments/resource-tabs.tsx b/apps/sim/app/playground/org/components/environments/resource-tabs.tsx new file mode 100644 index 00000000000..68cb5620349 --- /dev/null +++ b/apps/sim/app/playground/org/components/environments/resource-tabs.tsx @@ -0,0 +1,37 @@ +import { Chip } from '@sim/emcn' +import { + RESOURCE_TABS, + type ResourceTabId, +} from '@/app/playground/org/components/environments/mapping-model' + +interface ResourceTabsProps { + value: ResourceTabId + /** Rows per sub-tab, shown beside the label when there are any. */ + counts: ReadonlyMap + onChange: (id: ResourceTabId) => void +} + +/** The resource types of the mapping grid, in the sync view's order. */ +export function ResourceTabs({ value, counts, onChange }: ResourceTabsProps) { + return ( + + ) +} diff --git a/apps/sim/app/playground/org/components/environments/use-environment-mappings.ts b/apps/sim/app/playground/org/components/environments/use-environment-mappings.ts new file mode 100644 index 00000000000..abd6a763597 --- /dev/null +++ b/apps/sim/app/playground/org/components/environments/use-environment-mappings.ts @@ -0,0 +1,147 @@ +'use client' + +import { getErrorMessage } from '@sim/utils/errors' +import { type QueryFunctionContext, useQueries } from '@tanstack/react-query' +import { requestJson } from '@/lib/api/client/request' +import type { WorkspaceCredential } from '@/lib/api/contracts/credentials' +import { + type GetForkLineageResponse, + getForkDiffContract, + getForkLineageContract, + getForkMappingContract, +} from '@/lib/api/contracts/workspace-fork' +import { + buildMappingRows, + buildWorkflowRows, + type EnvironmentColumn, + type LineageEdge, + type MappingRow, + type WorkflowRow, +} from '@/app/playground/org/components/environments/mapping-model' +import { + forkKeys, + WORKSPACE_FORK_DIFF_STALE_TIME, + WORKSPACE_FORK_LINEAGE_STALE_TIME, + WORKSPACE_FORK_MAPPING_STALE_TIME, +} from '@/ee/workspace-forking/hooks/workspace-fork' +import { workspaceCredentialListQueryOptions } from '@/hooks/queries/utils/fetch-workspace-credentials' +import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' + +/** A mapping or diff request for one edge that failed, phrased for the grid. */ +export interface EdgeError { + edge: LineageEdge + message: string +} + +interface UseEnvironmentMappingsProps { + columns: readonly EnvironmentColumn[] + edges: readonly LineageEdge[] + /** Admin with forking available; the fork routes 404 or 403 otherwise. */ + forksEnabled: boolean + /** Off for a mock project, whose environments are not workspaces. */ + workflowsEnabled: boolean +} + +/** + * The per-edge mapping and sync preview for every direct edge of the lineage (each fork + * pulling from its parent), each environment's lineage node, and each environment's workflow + * list, joined into one row per resource. Shares its cache entries with the forks settings + * page through the same key factory, so an edit here shows there without a refetch. + */ +export function useEnvironmentMappings({ + columns, + edges, + forksEnabled, + workflowsEnabled, +}: UseEnvironmentMappingsProps) { + const mappings = useQueries({ + queries: edges.map((edge) => ({ + queryKey: forkKeys.mapping(edge.childId, edge.parentId, 'pull'), + queryFn: ({ signal }: QueryFunctionContext) => + requestJson(getForkMappingContract, { + params: { id: edge.childId }, + query: { otherWorkspaceId: edge.parentId, direction: 'pull' }, + signal, + }), + staleTime: WORKSPACE_FORK_MAPPING_STALE_TIME, + enabled: forksEnabled, + })), + }) + const diffs = useQueries({ + queries: edges.map((edge) => ({ + queryKey: forkKeys.diff(edge.childId, edge.parentId, 'pull'), + queryFn: ({ signal }: QueryFunctionContext) => + requestJson(getForkDiffContract, { + params: { id: edge.childId }, + query: { otherWorkspaceId: edge.parentId, direction: 'pull' }, + signal, + }), + staleTime: WORKSPACE_FORK_DIFF_STALE_TIME, + enabled: forksEnabled, + })), + }) + const lineages = useQueries({ + queries: columns.map((column) => ({ + queryKey: forkKeys.lineage(column.id), + queryFn: ({ signal }: QueryFunctionContext) => + requestJson(getForkLineageContract, { params: { id: column.id }, signal }), + staleTime: WORKSPACE_FORK_LINEAGE_STALE_TIME, + enabled: forksEnabled, + })), + }) + const credentialLists = useQueries({ + queries: columns.map((column) => ({ + ...workspaceCredentialListQueryOptions(column.id), + enabled: forksEnabled, + })), + }) + const workflowLists = useQueries({ + queries: columns.map((column) => ({ + ...getWorkflowListQueryOptions(column.id), + enabled: workflowsEnabled, + })), + }) + + /** A handful of edges and rows, so deriving them each render is cheaper than memoizing. */ + const lineageByEnv = new Map() + columns.forEach((column, index) => lineageByEnv.set(column.id, lineages[index]?.data)) + + /** Credentials by environment, so a credential row can show its provider's mark. */ + const credentialsByEnv = new Map>() + columns.forEach((column, index) => { + const byId = new Map() + for (const credential of credentialLists[index]?.data ?? []) byId.set(credential.id, credential) + credentialsByEnv.set(column.id, byId) + }) + + const deployedByEnv = new Map>() + columns.forEach((column, index) => { + const byName = new Map() + for (const workflow of workflowLists[index]?.data ?? []) { + byName.set(workflow.name, workflow.isDeployed ?? false) + } + deployedByEnv.set(column.id, byName) + }) + + const errors: EdgeError[] = [] + edges.forEach((edge, index) => { + const mappingError = mappings[index]?.error + const diffError = diffs[index]?.error + if (mappingError) errors.push({ edge, message: getErrorMessage(mappingError) }) + else if (diffError) errors.push({ edge, message: getErrorMessage(diffError) }) + }) + + const rows: MappingRow[] = buildMappingRows( + edges.map((edge, index) => ({ edge, entries: mappings[index]?.data?.entries ?? [] })) + ) + const workflowRows: WorkflowRow[] = buildWorkflowRows( + edges.map((edge, index) => ({ edge, workflows: diffs[index]?.data?.workflows ?? [] })), + deployedByEnv + ) + + const isPending = + forksEnabled && + (mappings.some((query) => query.isPending) || diffs.some((query) => query.isPending)) + + return { rows, workflowRows, lineageByEnv, credentialsByEnv, errors, isPending } +} diff --git a/apps/sim/app/playground/org/components/glyphs.tsx b/apps/sim/app/playground/org/components/glyphs.tsx index 7aaa529fca9..49b98a185ef 100644 --- a/apps/sim/app/playground/org/components/glyphs.tsx +++ b/apps/sim/app/playground/org/components/glyphs.tsx @@ -14,7 +14,7 @@ import type { FeedbackSource, IssueStatus, Priority, -} from '@/app/playground/org/lib/mock-data' +} from '@/app/playground/org/lib/types' const GLYPH = 'size-[14px] shrink-0' diff --git a/apps/sim/app/playground/org/components/investigation/evidence.tsx b/apps/sim/app/playground/org/components/investigation/evidence.tsx index 0b9b0fb66bd..aa2c65bc0fd 100644 --- a/apps/sim/app/playground/org/components/investigation/evidence.tsx +++ b/apps/sim/app/playground/org/components/investigation/evidence.tsx @@ -1,6 +1,6 @@ import { Chip, cn, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@sim/emcn' import { ArrowUpRight } from '@sim/emcn/icons' -import type { Evidence, RunBlock } from '@/app/playground/org/lib/infra-data' +import type { Evidence, RunBlock } from '@/app/playground/org/lib/types' const MONO = 'font-mono text-caption' diff --git a/apps/sim/app/playground/org/components/investigation/investigation-view.tsx b/apps/sim/app/playground/org/components/investigation/investigation-view.tsx index 5051a81f344..5cc5e65d75b 100644 --- a/apps/sim/app/playground/org/components/investigation/investigation-view.tsx +++ b/apps/sim/app/playground/org/components/investigation/investigation-view.tsx @@ -22,7 +22,7 @@ import type { Investigation, InvestigationStep, SourceMessage, -} from '@/app/playground/org/lib/infra-data' +} from '@/app/playground/org/lib/types' const STEP_ICONS: Record> = { feedback: MessageSquareText, diff --git a/apps/sim/app/playground/org/components/issue-page.tsx b/apps/sim/app/playground/org/components/issue-page.tsx index 65a9310810e..5adf6b87f4a 100644 --- a/apps/sim/app/playground/org/components/issue-page.tsx +++ b/apps/sim/app/playground/org/components/issue-page.tsx @@ -26,22 +26,16 @@ import { import { InvestigationView } from '@/app/playground/org/components/investigation/investigation-view' import { TicketIcon } from '@/app/playground/org/components/linked-tickets' import { MockComposer } from '@/app/playground/org/components/mock-composer' -import { chatsForIssue } from '@/app/playground/org/lib/changelog-data' import { - type Activity, - CHATS, - type Issue, - linkedTickets, - type PlanStep, PRIORITY_LABELS, - type Priority, - RELEASES, STATUS_LABELS, STATUS_ORDER, - ticketUrl, - type Workspace, -} from '@/app/playground/org/lib/mock-data' -import { protoRoutes } from '@/app/playground/org/lib/routes' +} from '@/app/playground/org/lib/issue-vocabulary' +import { type Project, realWorkspaceId } from '@/app/playground/org/lib/project' +import { useProjectSources } from '@/app/playground/org/lib/project-sources' +import { protoRoutes, workspaceRoutes } from '@/app/playground/org/lib/routes' +import type { Activity, Issue, PlanStep, Priority } from '@/app/playground/org/lib/types' +import { useProjectResources } from '@/app/playground/org/lib/use-project-resources' const RESOURCE_ICONS = { table: Table, @@ -52,24 +46,27 @@ const RESOURCE_ICONS = { } as const interface IssuePageProps { - workspace: Workspace + project: Project issue: Issue } /** Full-page issue, Linear-style: content column plus a properties column. */ -export function IssuePage({ workspace, issue }: IssuePageProps) { +export function IssuePage({ project, issue }: IssuePageProps) { + const sources = useProjectSources() + const resolveHref = useResourceHrefs(realWorkspaceId(project)) const [status, setStatus] = useState(issue.status) const [priority, setPriority] = useState(issue.priority) const [activity, setActivity] = useState(issue.activity) - const release = RELEASES.find((r) => r.id === issue.releaseId) - const tickets = linkedTickets(issue) - const chats = chatsForIssue(issue.key) + const release = sources.releaseFor(project, issue) + const tickets = sources.linkedTicketsFor(project, issue) + const chats = sources.issueChatsFor(project, issue.key) + const chatTitles = new Map(sources.chatsFor(project).map((chat) => [chat.id, chat.title])) return (
    - - {workspace.name} + + {project.name} / {issue.key} @@ -94,7 +91,7 @@ export function IssuePage({ workspace, issue }: IssuePageProps) { {tickets.map((ticket) => ( ( ( - {CHATS.find((c) => c.id === chat.id)?.title ?? `${chat.owner}’s chat`} + {chatTitles.get(chat.id) ?? `${chat.owner}’s chat`} {chat.running && } @@ -288,21 +285,44 @@ export function IssuePage({ workspace, issue }: IssuePageProps) { {issue.investigation && (
    Sources - {issue.investigation.sources.map((source) => ( - - {source.label} - - ))} + {issue.investigation.sources.map((source) => { + const href = resolveHref( + source.kind === 'workflow' ? 'workflow' : null, + source.label + ) + return href ? ( + + {source.label} + + ) : ( + + {source.label} + + ) + })}
    )} {issue.resources.length > 0 && (
    Resources - {issue.resources.map((resource) => ( - - {resource.name} - - ))} + {issue.resources.map((resource) => { + const href = resolveHref(resource.kind, resource.name) + return href ? ( + + {resource.name} + + ) : ( + + {resource.name} + + ) + })}
    )} @@ -405,3 +425,34 @@ function ActivityRow({ activity }: { activity: Activity }) {
    ) } + +type ResourceKind = Issue['resources'][number]['kind'] + +/** + * Resolves a pack resource to the real workspace page with the same name, so the mock issue's + * links land on the seeded workflow, table, knowledge base or file. Unmatched names stay plain. + */ +function useResourceHrefs(workspaceId: string) { + const { workflows, tables, knowledgeBases, files } = useProjectResources(workspaceId) + return (kind: ResourceKind | null, name: string): string | null => { + const wanted = name.trim().toLowerCase() + const matches = (candidate: string) => candidate.trim().toLowerCase() === wanted + if (kind === 'workflow' || kind === null) { + const workflow = workflows.find((w) => matches(w.name)) + if (workflow) return workspaceRoutes.workflow(workspaceId, workflow.id) + } + if (kind === 'table' || kind === null) { + const table = tables.find((t) => matches(t.name)) + if (table) return workspaceRoutes.table(workspaceId, table.id) + } + if (kind === 'knowledge' || kind === null) { + const base = knowledgeBases.find((b) => matches(b.name)) + if (base) return workspaceRoutes.knowledgeBase(workspaceId, base.id) + } + if (kind === 'file' || kind === 'dashboard' || kind === null) { + const file = files.find((f) => matches(f.name) || matches(f.name.replace(/\.[^.]+$/, ''))) + if (file) return workspaceRoutes.file(workspaceId, file.id) + } + return null + } +} diff --git a/apps/sim/app/playground/org/components/issues-list.tsx b/apps/sim/app/playground/org/components/issues-list.tsx index f2b197d4d44..005a0b2a1fe 100644 --- a/apps/sim/app/playground/org/components/issues-list.tsx +++ b/apps/sim/app/playground/org/components/issues-list.tsx @@ -1,19 +1,18 @@ +'use client' + import { chipHoverSurfaceClass, cn, OverflowText } from '@sim/emcn' import Link from 'next/link' import { DelegateAvatar } from '@/app/playground/org/components/delegate-avatar' import { AgentStateIcon, PriorityIcon, StatusIcon } from '@/app/playground/org/components/glyphs' -import { - ISSUES, - type Issue, - STATUS_LABELS, - STATUS_ORDER, - type Workspace, -} from '@/app/playground/org/lib/mock-data' +import { STATUS_LABELS, STATUS_ORDER } from '@/app/playground/org/lib/issue-vocabulary' +import type { Project } from '@/app/playground/org/lib/project' +import { useProjectSources } from '@/app/playground/org/lib/project-sources' import { protoRoutes } from '@/app/playground/org/lib/routes' +import type { Issue } from '@/app/playground/org/lib/types' /** Sim issues for a project, grouped by status. Tracker links and chats live on the issue page. */ -export function IssuesList({ workspace }: { workspace: Workspace }) { - const issues = ISSUES.filter((issue) => issue.workspaceId === workspace.id) +export function IssuesList({ project }: { project: Project }) { + const issues = useProjectSources().issuesFor(project) if (!issues.length) return

    No issues yet.

    return ( @@ -32,7 +31,7 @@ export function IssuesList({ workspace }: { workspace: Workspace }) {
  • {inStatus.map((issue) => ( - + ))}
    @@ -42,10 +41,10 @@ export function IssuesList({ workspace }: { workspace: Workspace }) { ) } -function IssueRow({ issue }: { issue: Issue }) { +function IssueRow({ issue, projectId }: { issue: Issue; projectId: string }) { return ( } +interface LinkedTicketsProps { + project: Project + issueKey: string +} + /** Compact marker next to a Sim issue key: one tracker ticket links straight out, several open a list. */ -export function LinkedTickets({ issueKey }: { issueKey: string }) { - const issue = issueByKey(issueKey) +export function LinkedTickets({ project, issueKey }: LinkedTicketsProps) { + const sources = useProjectSources() + const issue = sources.issuesFor(project).find((candidate) => candidate.key === issueKey) if (!issue) throw new Error(`Unknown issue ${issueKey}`) - const tickets = linkedTickets(issue) + const tickets = sources.linkedTicketsFor(project, issue) if (!tickets.length) return null const chrome = 'inline-flex h-[18px] shrink-0 items-center gap-1 rounded-[4px] px-1 text-[var(--text-muted)] text-caption opacity-70 transition-opacity hover:bg-[var(--surface-hover)] hover:opacity-100' @@ -36,7 +39,7 @@ export function LinkedTickets({ issueKey }: { issueKey: string }) { const [ticket] = tickets return (
    {tickets.map((ticket) => ( - + {ticket.key} {ticket.title} diff --git a/apps/sim/app/playground/org/components/live-chat.tsx b/apps/sim/app/playground/org/components/live-chat.tsx new file mode 100644 index 00000000000..96be5b798e4 --- /dev/null +++ b/apps/sim/app/playground/org/components/live-chat.tsx @@ -0,0 +1,107 @@ +'use client' + +import { useEffect, useRef } from 'react' +import { useSession } from '@/lib/auth/auth-client' +import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components' +import { + getMothershipUseChatOptions, + useChat, +} from '@/app/workspace/[workspaceId]/home/hooks/use-chat' + +interface LiveChatProps { + /** A workspace id, or the organization for an org-wide chat. */ + owner: string | { organizationId: string } + /** Existing chat to load; omit to start a new one on the first message. */ + chatId?: string + /** Sent once when the chat starts without an id: the message handed over from the home composer. */ + initialMessage?: string + onInitialMessageSent?: () => void + /** A new chat got its id from the server. */ + onChatCreated?: (chatId: string) => void + layout: 'mothership-view' | 'copilot-view' + className?: string +} + +/** The real Sim Chat over the shared streaming hook; the surface around it owns the URL. */ +export function LiveChat({ + owner, + chatId, + initialMessage, + onInitialMessageSent, + onChatCreated, + layout, + className, +}: LiveChatProps) { + const { data: session } = useSession() + const { + messages, + isChatHistoryPending, + isSending, + isReconnecting, + sendMessage, + stopGeneration, + resolvedChatId, + messageQueue, + removeFromQueue, + sendNow, + editQueuedMessage, + cancelQueueEdit, + editingQueuedId, + dispatchingHeadId, + } = useChat( + owner, + chatId, + getMothershipUseChatOptions({ requestMode: 'agent', syncChatUrl: false }) + ) + + const sentInitialRef = useRef(false) + useEffect(() => { + if (!initialMessage || chatId || sentInitialRef.current) return + /** + * Deferred one tick: a send started inside the mount effect is withdrawn by the hook's own + * unmount cleanup when React (StrictMode in dev) simulates a remount, and the cleanup here + * cancels the timer in that case so the second mount sends it exactly once. + */ + const timer = window.setTimeout(() => { + sentInitialRef.current = true + void sendMessage(initialMessage) + onInitialMessageSent?.() + }, 0) + return () => window.clearTimeout(timer) + }, [initialMessage, chatId, sendMessage, onInitialMessageSent]) + + const announcedRef = useRef(null) + useEffect(() => { + if (!resolvedChatId || chatId || announcedRef.current === resolvedChatId) return + announcedRef.current = resolvedChatId + onChatCreated?.(resolvedChatId) + }, [resolvedChatId, chatId, onChatCreated]) + + const ownerKey = typeof owner === 'string' ? owner : `org:${owner.organizationId}` + + return ( + + void sendMessage(text, fileAttachments, contexts) + } + onStopGeneration={() => void stopGeneration()} + messageQueue={messageQueue} + editingQueuedId={editingQueuedId} + dispatchingHeadId={dispatchingHeadId} + onRemoveQueuedMessage={removeFromQueue} + onSendQueuedMessage={sendNow} + onEditQueuedMessage={editQueuedMessage} + onCancelQueueEdit={cancelQueueEdit} + userId={session?.user?.id} + chatId={resolvedChatId} + draftScopeKey={`proto:${ownerKey}:${chatId ?? 'new'}`} + layout={layout} + /> + ) +} diff --git a/apps/sim/app/playground/org/components/new-chat-home.tsx b/apps/sim/app/playground/org/components/new-chat-home.tsx index 04d44881e61..c7198fe930b 100644 --- a/apps/sim/app/playground/org/components/new-chat-home.tsx +++ b/apps/sim/app/playground/org/components/new-chat-home.tsx @@ -15,20 +15,80 @@ import { import { Check, ChevronDown, Folder, Plus, X } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' -import { MockComposer } from '@/app/playground/org/components/mock-composer' -import { PEOPLE, WORKSPACES } from '@/app/playground/org/lib/mock-data' +import { useSession } from '@/lib/auth/auth-client' +import type { OrganizationSurfaceContext } from '@/lib/organizations/surface' +import { OrganizationHome } from '@/app/o/[organizationId]/home/organization-home' +import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider' +import { type Project, useProjects } from '@/app/playground/org/lib/project' import { protoRoutes } from '@/app/playground/org/lib/routes' import { protoParsers } from '@/app/playground/org/lib/search-params' +import { ChatSurfaceProvider, UserInput } from '@/app/workspace/[workspaceId]/home/components' const NO_PROJECT = 'none' -/** New chat, Codex-style: pick the project in the heading or on the composer, then ask. */ -export function NewChatHome() { +interface NewChatHomeProps { + /** The organization to chat in; without one, only project chats can start here. */ + organization: OrganizationSurfaceContext | null +} + +/** + * New chat: the organization Home from the product (Build, Plan and Search), plus a project + * picker. Build with a project picked opens that project's chat; everything else is the + * organization chat exactly as the product runs it. + */ +export function NewChatHome({ organization }: NewChatHomeProps) { const router = useRouter() + const { data: session } = useSession() + const { roots: projects } = useProjects() const [{ project }, setParams] = useQueryStates(protoParsers) - const selected = WORKSPACES.find((w) => w.id === project) + /** The first project is the default so a fresh visit lands in a project, not an org-wide chat. */ + const selected = + project === NO_PROJECT ? undefined : (projects.find((p) => p.id === project) ?? projects[0]) const select = (id: string) => void setParams({ project: id }) - const firstName = PEOPLE.teddy.name.split(' ')[0] + const firstName = session?.user?.name?.split(' ')[0] + + /** Hands a build message to the picked project's chat; false leaves it to the org Home. */ + const openProjectChat = (message: string): boolean => { + if (!selected) return false + router.push( + selected.isMock + ? `${protoRoutes.workspace(selected.id)}?chat=new` + : `${protoRoutes.workspace(selected.id)}?chat=new&q=${encodeURIComponent(message)}` + ) + return true + } + + if (organization?.mothershipAvailable) { + return ( + + + mode === 'agent' + ? { + heading: selected ? `What should we build in ${selected.name}?` : undefined, + accessory: ( +
    + + + {selected ? selected.name : 'No project'} + + +
    + ), + } + : undefined + } + onBeforeSend={(message, mode) => mode === 'agent' && openProjectChat(message)} + /> +
    + ) + } return (
    @@ -36,7 +96,7 @@ export function NewChatHome() { {selected ? ( <> What should we build in{' '} - +
    ) } interface ProjectMenuProps { + projects: Project[] value: string onSelect: (id: string) => void children: React.ReactElement } -function ProjectMenu({ value, onSelect, children }: ProjectMenuProps) { +function ProjectMenu({ projects, value, onSelect, children }: ProjectMenuProps) { const [search, setSearch] = useState('') - const matches = WORKSPACES.filter((w) => w.name.toLowerCase().includes(search.toLowerCase())) + const matches = projects.filter((p) => p.name.toLowerCase().includes(search.toLowerCase())) return ( !open && setSearch('')}> {children} @@ -88,11 +156,11 @@ function ProjectMenu({ value, onSelect, children }: ProjectMenuProps) { value={search} onChange={(event) => setSearch(event.target.value)} /> - {matches.map((workspace) => ( - onSelect(workspace.id)}> + {matches.map((project) => ( + onSelect(project.id)}> - {workspace.name} - + {project.name} + ))} diff --git a/apps/sim/app/playground/org/components/project-row.tsx b/apps/sim/app/playground/org/components/project-row.tsx new file mode 100644 index 00000000000..9759c42656a --- /dev/null +++ b/apps/sim/app/playground/org/components/project-row.tsx @@ -0,0 +1,288 @@ +'use client' + +import { useCallback, useRef, useState } from 'react' +import { + ChipTag, + chipVariants, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + OverflowText, + RowActions, + rowActionsGroupClass, +} from '@sim/emcn' +import { + Duplicate, + MoreHorizontal, + Pencil, + Pin, + PinOff, + Plus, + SquareArrowUpRight, + Trash, +} from '@sim/emcn/icons' +import Link from 'next/link' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import type { Project } from '@/app/playground/org/lib/project' +import { useProjectActions } from '@/app/playground/org/lib/use-project-actions' +import type { ProjectDragProps } from '@/app/playground/org/lib/use-project-order' +import { SidebarTooltip } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { SidebarRowAction } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-row-actions' +import { DeleteModal } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/delete-modal/delete-modal' +import { SIDEBAR_RAIL_CHIP_CLASS } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { + useItemDrag, + useItemRename, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { createSidebarDragGhost } from '@/app/workspace/[workspaceId]/w/components/sidebar/utils' +import { useContextMenu } from '@/hooks/use-context-menu' + +interface ProjectRowProps { + project: Project + href: string + newChatHref: string + active: boolean + /** Whether the chats under the row are shown; undefined when there are none to show. */ + expanded?: boolean + needsYou: number + railCollapsed: boolean + drag: ProjectDragProps + isAnyDragActive: boolean + onToggleExpand: () => void +} + +/** + * A project in the organization sidebar with the workspace sidebar's row ergonomics: hover + * reveals the options menu and a new-chat button, right-click opens the same menu, double-click + * or Rename edits the name inline, drag reorders, and the menu pins or deletes the project. + */ +export function ProjectRow({ + project, + href, + newChatHref, + active, + expanded, + needsYou, + railCollapsed, + drag, + isAnyDragActive, + onToggleExpand, +}: ProjectRowProps) { + const actions = useProjectActions() + const pinned = actions.isPinned(project) + const [deleteOpen, setDeleteOpen] = useState(false) + const dragGhostRef = useRef(null) + + const { + isOpen: menuOpen, + position, + menuRef, + handleContextMenu, + openMenuAt, + closeMenu, + } = useContextMenu() + + const rename = useItemRename({ + initialName: project.name, + onSave: (name) => actions.rename(project, name), + itemType: 'workspace', + itemId: project.id, + }) + + const { isDragging, shouldPreventClickRef, handleDragStart, handleDragEnd } = useItemDrag({ + onDragStart: (event) => { + event.dataTransfer.effectAllowed = 'move' + const ghost = createSidebarDragGhost(project.name) + void ghost.offsetHeight + event.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + dragGhostRef.current = ghost + drag.onDragStart() + }, + }) + + const onDragEnd = useCallback(() => { + dragGhostRef.current?.remove() + dragGhostRef.current = null + handleDragEnd() + drag.onDragEnd() + }, [drag, handleDragEnd]) + + const openMenuFromButton = (event: React.MouseEvent) => { + event.preventDefault() + event.stopPropagation() + const rect = event.currentTarget.getBoundingClientRect() + openMenuAt({ x: rect.right, y: rect.bottom }) + } + + const openInNewTab = () => window.open(href, '_blank', 'noopener') + const copyLink = () => void navigator.clipboard.writeText(`${window.location.origin}${href}`) + + return ( + <> + +
    + {drag.indicator && ( + + )} + {rename.isEditing ? ( +
    + + rename.setEditValue(event.target.value)} + onKeyDown={rename.handleKeyDown} + onBlur={rename.handleInputBlur} + disabled={rename.isRenaming} + onClick={(event) => event.stopPropagation()} + className='w-full min-w-0 border-0 bg-transparent p-0 text-[var(--text-body)] text-sm outline-hidden focus:outline-hidden focus:ring-0 focus-visible:outline-hidden focus-visible:ring-0 focus-visible:ring-offset-0' + maxLength={100} + autoComplete='off' + autoCorrect='off' + autoCapitalize='off' + spellCheck={false} + /> +
    + ) : ( + { + if (shouldPreventClickRef.current) { + event.preventDefault() + return + } + onToggleExpand() + }} + onDoubleClick={(event) => { + if (project.isMock) return + event.preventDefault() + rename.handleStartEdit() + }} + aria-expanded={expanded} + className='flex min-w-0 flex-1 items-center gap-2' + > + + + + )} + {!railCollapsed && !rename.isEditing && ( + 0 ? ( + + {needsYou} + + ) : pinned ? ( + + ) : undefined + } + > + + + + {!project.isMock && ( + + + + )} + + )} +
    +
    + + !open && closeMenu()} modal={false}> + +
    + + event.preventDefault()} + > + + + Open in new tab + + + + Copy link + + rename.handleStartEdit()}> + + Rename + + actions.setPinned(project, !pinned)}> + {pinned ? : } + {pinned ? 'Unpin' : 'Pin'} + + + setDeleteOpen(true)}> + + Delete + + + + + setDeleteOpen(false)} + onConfirm={() => { + void actions.remove(project).finally(() => setDeleteOpen(false)) + }} + isDeleting={actions.isDeleting} + itemType='workspace' + itemName={ + project.environments.length > 1 + ? `${project.name} (${project.environments.length} environments)` + : project.name + } + /> + + ) +} diff --git a/apps/sim/app/playground/org/components/project-settings.tsx b/apps/sim/app/playground/org/components/project-settings.tsx index a5475cad829..412daa813ab 100644 --- a/apps/sim/app/playground/org/components/project-settings.tsx +++ b/apps/sim/app/playground/org/components/project-settings.tsx @@ -1,20 +1,22 @@ 'use client' import type { ReactNode } from 'react' -import { Avatar, Chip, ChipTag } from '@sim/emcn' -import { Key, Plus } from '@sim/emcn/icons' -import { JiraIcon, LinearIcon, SlackMonoIcon } from '@/components/icons' -import { PEOPLE, type Workspace } from '@/app/playground/org/lib/mock-data' +import { Chip } from '@sim/emcn' +import { JiraIcon, LinearIcon } from '@/components/icons' +import type { Project } from '@/app/playground/org/lib/project' import { SETTINGS_NAV } from '@/app/playground/org/lib/settings-nav' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useProjectDescription } from '@/app/playground/org/lib/use-project-resources' interface ProjectSettingsProps { - workspace: Workspace + project: Project sectionId: string } -/** One settings section; the settings list itself lives in the sidebar. */ -export function ProjectSettings({ workspace, sectionId }: ProjectSettingsProps) { +/** + * One settings section of a mock project; the settings list itself lives in the sidebar. The + * project's General page is the prototype's own; the workspace sections have nothing behind them. + */ +export function ProjectSettings({ project, sectionId }: ProjectSettingsProps) { const active = SETTINGS_NAV.find((item) => item.id === sectionId) if (!active) throw new Error(`Unknown settings section ${sectionId}`) const ActiveIcon = active.icon @@ -27,236 +29,35 @@ export function ProjectSettings({ workspace, sectionId }: ProjectSettingsProps)

    {active.label}

    {active.description}

    - {PRIMARY_ACTION[active.id] && ( - - {PRIMARY_ACTION[active.id]} - - )} - + {active.group === 'project' ? ( + + ) : ( + Nothing here yet. + )} ) } -const PRIMARY_ACTION: Record = { - teammates: 'Invite', - secrets: 'Add secret', - byok: 'Add key', - sandboxes: 'Add package', - 'custom-tools': 'New tool', - mcp: 'Add server', - 'workflow-mcp-servers': 'New server', - 'api-keys': 'Create key', - 'custom-blocks': 'Publish block', -} - -function Rows({ children }: { children: ReactNode }) { - return
    {children}
    -} - -function Muted({ children }: { children: ReactNode }) { - return {children} -} - -function SectionContent({ id, workspace }: { id: string; workspace: Workspace }) { - switch (id) { - case 'project': - return - case 'teammates': - return ( - - {Object.values(PEOPLE).map((person, index) => ( - } - title={person.name} - description={`${person.id}@acme.com`} - trailing={ - - {index === 0 ? 'Admin' : index === 1 ? 'Write' : 'Read'} - - } - /> - ))} - - ) - case 'secrets': - return ( - - {['SLACK_BOT_TOKEN', 'LINEAR_API_KEY', 'ZENDESK_TOKEN', 'STRIPE_READ_KEY'].map((key) => ( - } - title={{key}} - description='••••••••••••' - trailing={Updated 3d ago} - /> - ))} - - ) - case 'byok': - return ( - - {[ - ['Anthropic', 'Connected · used by 6 workflows'], - ['OpenAI', 'Connected · used by 2 workflows'], - ['Google Gemini', 'Not set — hosted key in use'], - ].map(([name, status]) => ( - } title={name} description={status} /> - ))} - - ) - case 'sandboxes': - return ( - - {[ - ['pandas', 'Python · 2.2.3'], - ['requests', 'Python · 2.32'], - ['zod', 'npm · 3.23'], - ].map(([name, version]) => ( - {name}} - description={version} - /> - ))} - - ) - case 'custom-tools': - return ( - - {[ - ['lookup_account', 'Find an account by email in the CRM'], - ['score_churn', 'Return a churn score for one account'], - ].map(([name, desc]) => ( - {name}} - description={desc} - navigable - /> - ))} - - ) - case 'mcp': - return ( - - } - navigable - /> - } - navigable - /> - - ) - case 'workflow-mcp-servers': - return ( - - - - ) - case 'api-keys': - return ( - - {['CI deploys', 'Zapier bridge'].map((name) => ( - } - title={name} - description='sk-sim-••••••••4f2a' - trailing={Last used 2h ago} - /> - ))} - - ) - case 'inbox': - return ( - - - - ) - case 'recently-deleted': - return ( - - {[ - ['old-support-bot', 'Workflow · deleted 4d ago'], - ['alerts_backup', 'Table · deleted 12d ago'], - ].map(([name, meta]) => ( - Restore} - /> - ))} - - ) - case 'forks': - return ( - - Sync} - /> - - ) - case 'custom-blocks': - return ( - - - - ) - case 'requests': - return No open requests. - default: - return Nothing here yet. - } -} - -function ProjectGeneral({ workspace }: { workspace: Workspace }) { +function ProjectGeneral({ project }: { project: Project }) { + const { tracker, feedbackSources } = project.mock + const description = useProjectDescription(project) const TrackerIcon = - workspace.tracker.kind === 'jira' - ? JiraIcon - : workspace.tracker.kind === 'linear' - ? LinearIcon - : null + tracker.kind === 'jira' ? JiraIcon : tracker.kind === 'linear' ? LinearIcon : null const rows: [string, ReactNode][] = [ - ['Name', workspace.name], - ['Description', workspace.description], + ['Name', project.name], + ['Description', description], [ 'Tracker', {TrackerIcon && } - {workspace.tracker.kind === 'sim' - ? 'Sim tracker' - : `${workspace.tracker.label} · two-way sync`} + {tracker.kind === 'sim' ? 'Sim tracker' : `${tracker.label} · two-way sync`} , ], [ 'Feedback sources', - workspace.feedbackSources.length - ? workspace.feedbackSources.map((source) => source.label).join(', ') - : 'None', + feedbackSources.length ? feedbackSources.map((source) => source.label).join(', ') : 'None', ], ['Triage', 'Sim proposes issues from feedback; a person accepts them'], ['Reporter replies', 'Reply in the original thread when a change ships'], diff --git a/apps/sim/app/playground/org/components/project-shell.tsx b/apps/sim/app/playground/org/components/project-shell.tsx index e04242310eb..2b9c3e4bd90 100644 --- a/apps/sim/app/playground/org/components/project-shell.tsx +++ b/apps/sim/app/playground/org/components/project-shell.tsx @@ -1,45 +1,62 @@ 'use client' -import type { ReactNode } from 'react' +import { type ReactNode, useCallback, useRef } from 'react' import { Avatar, cn } from '@sim/emcn' import { X } from '@sim/emcn/icons' import { useQueryStates } from 'nuqs' import { AgentRun } from '@/app/playground/org/components/agent-run' import { ChatThread } from '@/app/playground/org/components/chat-thread' import { RunningDot } from '@/app/playground/org/components/glyphs' +import { LiveChat } from '@/app/playground/org/components/live-chat' import { MockComposer } from '@/app/playground/org/components/mock-composer' -import { DRAFTS } from '@/app/playground/org/lib/changelog-data' -import { CHATS, type Chat, type Workspace } from '@/app/playground/org/lib/mock-data' +import type { Project } from '@/app/playground/org/lib/project' +import { useProjectSources } from '@/app/playground/org/lib/project-sources' import { protoParsers } from '@/app/playground/org/lib/search-params' +import { useMothershipChats } from '@/hooks/queries/mothership-chats' + +const NEW_CHAT = 'new' interface ProjectShellProps { - workspace: Workspace + project: Project children: ReactNode } /** Project page with the chat you opened in a side panel on the left; the page stays usable beside it. */ -export function ProjectShell({ workspace, children }: ProjectShellProps) { - const [{ chat: chatId }, setParams] = useQueryStates(protoParsers) - const chat: Chat | undefined = - chatId === 'new' - ? { id: 'new', title: `New chat in ${workspace.name}`, workspaceId: workspace.id, age: 'now' } - : chatId - ? CHATS.find((c) => c.id === chatId && c.workspaceId === workspace.id) - : undefined +export function ProjectShell({ project, children }: ProjectShellProps) { + const [{ chat: chatId, q }, setParams] = useQueryStates(protoParsers) + /** The id a new chat resolved to; the panel keeps its key so the thread survives the URL change. */ + const createdFromNewRef = useRef(null) + const panelKey = chatId === NEW_CHAT || chatId === createdFromNewRef.current ? NEW_CHAT : chatId + + const onChatCreated = useCallback( + (id: string) => { + createdFromNewRef.current = id + void setParams({ chat: id }, { history: 'replace' }) + }, + [setParams] + ) + const onInitialMessageSent = useCallback( + () => void setParams({ q: null }, { history: 'replace' }), + [setParams] + ) + return (
    - {chat && ( + {chatId && ( void setParams({ chat: null }, { history: 'replace' })} + key={panelKey} + project={project} + chatId={chatId} + initialMessage={q || undefined} + onChatCreated={onChatCreated} + onInitialMessageSent={onInitialMessageSent} + onClose={() => void setParams({ chat: null, q: null }, { history: 'replace' })} /> )}
    @@ -49,44 +66,72 @@ export function ProjectShell({ workspace, children }: ProjectShellProps) { } interface SideChatProps { - chat: Chat - workspace: Workspace + project: Project + chatId: string + initialMessage?: string + onChatCreated: (chatId: string) => void + onInitialMessageSent: () => void onClose: () => void } -function SideChat({ chat, workspace, onClose }: SideChatProps) { - const runs = - DRAFTS.find((draft) => draft.workspaceId === workspace.id)?.running.filter( - (work) => work.chat.id === chat.id - ) ?? [] - const running = runs.length > 0 +function SideChat({ + project, + chatId, + initialMessage, + onChatCreated, + onInitialMessageSent, + onClose, +}: SideChatProps) { + const sources = useProjectSources() + const { data: chats } = useMothershipChats(project.isMock ? undefined : project.id) + const realChat = chats?.find((chat) => chat.id === chatId) + /** Chats from the project source (changelog and issue stories) keep their canned threads. */ + const mockChat = sources.chatsFor(project).find((chat) => chat.id === chatId) + const runs = mockChat + ? (sources + .changelogFor(project) + .draft?.running.filter((work) => work.chat.id === mockChat.id) ?? []) + : [] + const running = runs.length > 0 || Boolean(realChat?.isActive) + const title = + chatId === NEW_CHAT && !realChat + ? `New chat in ${project.name}` + : (realChat?.name ?? mockChat?.title ?? 'Chat') return (
    {running && } - - {chat.title} - - {chat.owner && } + {title} + {mockChat?.owner && }
    - {running ? ( + {runs.length > 0 ? (
    {runs.map((work) => ( - + ))}
    - ) : chat.id === 'new' ? ( + ) : mockChat ? ( + + ) : project.isMock ? (
    - +
    ) : ( - + )}
    ) diff --git a/apps/sim/app/playground/org/components/proto-dashboard.tsx b/apps/sim/app/playground/org/components/proto-dashboard.tsx index e730431939a..931f95e6ca8 100644 --- a/apps/sim/app/playground/org/components/proto-dashboard.tsx +++ b/apps/sim/app/playground/org/components/proto-dashboard.tsx @@ -1,5 +1,6 @@ 'use client' +import type { ReactNode } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -10,25 +11,56 @@ import { import { Check, ChevronDown, Plus } from '@sim/emcn/icons' import { useQueryStates } from 'nuqs' import { DashboardPreview } from '@/components/dashboards/dashboard-preview' +import { DashboardFile } from '@/app/playground/org/components/dashboard-file' +import { RunsDashboard } from '@/app/playground/org/components/runs-dashboard' +import { mockTableAnalytics } from '@/app/playground/org/fixtures' import { MOCK_DASHBOARDS } from '@/app/playground/org/lib/dashboards' -import { mockTableAnalytics } from '@/app/playground/org/lib/mock-analytics' -import type { Workspace } from '@/app/playground/org/lib/mock-data' +import { type Project, realWorkspaceId } from '@/app/playground/org/lib/project' import { protoParsers } from '@/app/playground/org/lib/search-params' +import { useProjectResources } from '@/app/playground/org/lib/use-project-resources' import { TableAnalyticsSourceContext } from '@/hooks/queries/table-analytics' -/** The real dashboard renderer over seeded mock rows, with a switcher on the title. */ -export function ProtoDashboard({ workspace }: { workspace: Workspace }) { +const RUNS_ID = 'runs' + +interface DashboardOption { + id: string + title: string + kind: 'file' | 'runs' | 'sample' +} + +/** + * The project's dashboards: its real dashboard files rendered live, a built-in Runs dashboard + * from the workspace's execution stats, and the pack's sample dashboards only when the + * workspace has no dashboard file of its own. + */ +export function ProtoDashboard({ project }: { project: Project }) { const [{ dashboard: selected }, setParams] = useQueryStates(protoParsers) - if (!workspace.dashboards.length) + const { dashboardFiles, filesPending } = useProjectResources(realWorkspaceId(project)) + + if (filesPending) + return

    Loading…

    + + const samples = (suffix: string): DashboardOption[] => + project.mock.dashboards.map((id) => ({ + id, + title: `${MOCK_DASHBOARDS[id]?.title ?? id}${suffix}`, + kind: 'sample' as const, + })) + /** A mock project has only its pack's dashboards; a real one leads with its own files and runs. */ + const options: DashboardOption[] = project.isMock + ? samples('') + : [ + ...dashboardFiles.map((file) => ({ id: file.id, title: file.name, kind: 'file' as const })), + { id: RUNS_ID, title: 'Runs', kind: 'runs' }, + ...(dashboardFiles.length ? [] : samples(' (sample)')), + ] + if (!options.length) return (

    - No dashboards yet. Ask Sim to build one from this workspace’s tables. + No dashboards yet. Ask Sim to build one from this project’s tables.

    ) - const id = - selected && workspace.dashboards.includes(selected) ? selected : workspace.dashboards[0] - const dashboard = MOCK_DASHBOARDS[id] - if (!dashboard) throw new Error(`Unknown mock dashboard ${id}`) + const current = options.find((option) => option.id === selected) ?? options[0] const title = ( @@ -37,18 +69,18 @@ export function ProtoDashboard({ workspace }: { workspace: Workspace }) { type='button' className='flex items-center gap-2 self-start rounded-lg text-left @min-[1000px]/dashboard:text-[32px] text-[28px] text-[var(--text-primary)] leading-tight tracking-[-0.02em]' > - {dashboard.title} + {current.title} - {workspace.dashboards.map((option) => ( + {options.map((option) => ( void setParams({ dashboard: option as typeof selected })} + key={option.id} + onSelect={() => void setParams({ dashboard: option.id }, { history: 'replace' })} > - {MOCK_DASHBOARDS[option]?.title} - {option === id && } + {option.title} + {option.id === current.id && } ))} @@ -60,13 +92,32 @@ export function ProtoDashboard({ workspace }: { workspace: Workspace }) { ) + return +} + +interface DashboardBodyProps { + project: Project + option: DashboardOption + title: ReactNode +} + +function DashboardBody({ project, option, title }: DashboardBodyProps) { + const { dashboardFiles } = useProjectResources(realWorkspaceId(project)) + if (option.kind === 'runs') return + if (option.kind === 'file') { + const file = dashboardFiles.find((candidate) => candidate.id === option.id) + if (!file) return null + return + } + const sample = MOCK_DASHBOARDS[option.id] + if (!sample) return null return ( diff --git a/apps/sim/app/playground/org/components/proto-page.tsx b/apps/sim/app/playground/org/components/proto-page.tsx index 8edfc0fe4fa..783e88e1363 100644 --- a/apps/sim/app/playground/org/components/proto-page.tsx +++ b/apps/sim/app/playground/org/components/proto-page.tsx @@ -1,65 +1,131 @@ 'use client' +import type { ReactNode } from 'react' import { notFound } from 'next/navigation' -import { ChatThread } from '@/app/playground/org/components/chat-thread' +import type { OrganizationSurfaceContext } from '@/lib/organizations/surface' import { IssuePage } from '@/app/playground/org/components/issue-page' +import { LiveChat } from '@/app/playground/org/components/live-chat' import { NewChatHome } from '@/app/playground/org/components/new-chat-home' import { ProjectShell } from '@/app/playground/org/components/project-shell' import { WorkspaceView } from '@/app/playground/org/components/workspace-view' -import { CHATS, issueByKey, WORKSPACES } from '@/app/playground/org/lib/mock-data' +import { type Project, useProject, useProjects } from '@/app/playground/org/lib/project' +import { useProjectSources } from '@/app/playground/org/lib/project-sources' import { parseProtoRoute } from '@/app/playground/org/lib/routes' +import { useOrganizationMothershipChats } from '@/hooks/queries/mothership-chats' -/** Client-side router for the prototype's catch-all route. */ -export function ProtoPage({ slug }: { slug?: string[] }) { +/** Client-side router for the prototype's catch-all and project routes. */ +interface ProtoPageProps { + slug?: string[] + /** The organization the home page chats in; resolved on the server for the home route only. */ + organization?: OrganizationSurfaceContext | null +} + +export function ProtoPage({ slug, organization = null }: ProtoPageProps) { const route = parseProtoRoute(slug) if (!route) notFound() switch (route.kind) { case 'home': - return + return case 'search': return case 'connectors': return - case 'chat': { - const chat = CHATS.find((c) => c.id === route.chatId) - if (!chat) notFound() - return ( -
    -
    - {chat.title} -
    - -
    - ) - } - case 'workspace': { - const workspace = WORKSPACES.find((w) => w.id === route.workspaceId) - if (!workspace) notFound() + case 'chat': + return + case 'workspace': return ( - - - + + {(project) => ( + + + + )} + ) - } - case 'issue': { - const workspace = WORKSPACES.find((w) => w.id === route.workspaceId) - const issue = issueByKey(route.issueKey) - if (!workspace || !issue || issue.workspaceId !== workspace.id) notFound() + case 'issue': return ( - - - + + {(project) => } + ) - } } } +interface ProjectRouteProps { + workspaceId: string + children: (project: Project) => ReactNode +} + +/** Resolves the real workspace behind a project route before rendering it. */ +function ProjectRoute({ workspaceId, children }: ProjectRouteProps) { + const { project, isPending } = useProject(workspaceId) + if (project) return <>{children(project)} + if (isPending) return + notFound() +} + +interface IssueRouteProps { + project: Project + issueKey: string +} + +/** One of the project's issues; the source may still be settling while the workflow lists load. */ +function IssueRoute({ project, issueKey }: IssueRouteProps) { + const issue = useProjectSources() + .issuesFor(project) + .find((candidate) => candidate.key === issueKey) + if (!issue) { + if (project.overlayPending) return + notFound() + } + return ( + + + + ) +} + +/** An org-wide chat: Sim over the organization instead of one project. */ +function OrgChat({ chatId }: { chatId: string }) { + const { projects, isPending } = useProjects() + const organizationId = projects.find((project) => project.organizationId)?.organizationId + if (!organizationId) + return isPending ? ( + + ) : ( + + ) + return +} + +function OrgChatView({ organizationId, chatId }: { organizationId: string; chatId: string }) { + const { data: chats } = useOrganizationMothershipChats(organizationId) + const title = chats?.find((chat) => chat.id === chatId)?.name ?? 'Chat' + return ( +
    +
    + {title} +
    + +
    + ) +} + +function Loading({ label }: { label: string }) { + return

    {label}

    +} + function Placeholder({ title, body }: { title: string; body: string }) { return (
    diff --git a/apps/sim/app/playground/org/components/proto-shell.tsx b/apps/sim/app/playground/org/components/proto-shell.tsx index ce894e12233..10bfdf74780 100644 --- a/apps/sim/app/playground/org/components/proto-shell.tsx +++ b/apps/sim/app/playground/org/components/proto-shell.tsx @@ -2,12 +2,17 @@ import type { ReactNode } from 'react' import { ProtoSidebar } from '@/app/playground/org/components/proto-sidebar' +import { fixtureProjectSources } from '@/app/playground/org/fixtures' +import { ProjectSourcesProvider } from '@/app/playground/org/lib/project-sources' import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' +/** The playground shell: the org sidebar, with the fixture packs as the project sources. */ export function ProtoShell({ children }: { children: ReactNode }) { return ( -
    - }>{children} -
    + +
    + }>{children} +
    +
    ) } diff --git a/apps/sim/app/playground/org/components/proto-sidebar.tsx b/apps/sim/app/playground/org/components/proto-sidebar.tsx index 330be70ac51..15aefa692f0 100644 --- a/apps/sim/app/playground/org/components/proto-sidebar.tsx +++ b/apps/sim/app/playground/org/components/proto-sidebar.tsx @@ -2,10 +2,8 @@ import { useRef, useState } from 'react' import { - Avatar, Chip, ChipLink, - ChipTag, chipVariants, cn, OverflowText, @@ -16,28 +14,26 @@ import { import { ChevronDown, ChevronLeft, - Home, Integration, PanelLeft, Plus, Search, - Settings, + SquarePen, } from '@sim/emcn/icons' +import { formatRelativeTime } from '@sim/utils/formatting' import Link from 'next/link' -import { usePathname } from 'next/navigation' +import { usePathname, useRouter } from 'next/navigation' import { useQueryStates } from 'nuqs' import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { useActiveOrganization, useSession } from '@/lib/auth/auth-client' +import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links' +import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { RunningDot } from '@/app/playground/org/components/glyphs' -import { DRAFTS } from '@/app/playground/org/lib/changelog-data' -import { - CHATS, - type Chat, - ORGANIZATION, - PEOPLE, - WORKSPACES, - type Workspace, -} from '@/app/playground/org/lib/mock-data' +import { ProjectRow } from '@/app/playground/org/components/project-row' +import { type Project, useProjects } from '@/app/playground/org/lib/project' +import { type ProjectChat, useProjectSources } from '@/app/playground/org/lib/project-sources' import { + BUILD_SECTION_IDS, fullViewProject, MAIN_SECTION_IDS, PROTO_BASE, @@ -52,9 +48,12 @@ import { SETTINGS_GROUPS, SETTINGS_NAV, } from '@/app/playground/org/lib/settings-nav' +import { type ProjectDragProps, useProjectOrder } from '@/app/playground/org/lib/use-project-order' import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' import { + HelpModal, isNavItemActive, + SidebarFooter, SidebarNavChip, type SidebarNavItemData, SidebarSection, @@ -67,26 +66,77 @@ import { SIDEBAR_RAIL_CHIP_CLASS, SIDEBAR_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { + type MothershipChatMetadata, + useMothershipChats, + useOrganizationMothershipChats, +} from '@/hooks/queries/mothership-chats' import { useSidebarStore } from '@/stores/sidebar/store' const CHAT_PREVIEW = 3 -const GENERAL_CHATS = CHATS.filter((chat) => !chat.workspaceId) -const RUNNING_CHAT_IDS = new Set(DRAFTS.flatMap((draft) => draft.running.map((w) => w.chat.id))) const NAV_ITEMS: SidebarNavItemData[] = [ - { id: 'home', label: 'New chat', icon: Home, href: PROTO_BASE }, + { id: 'home', label: 'New chat', icon: SquarePen, href: PROTO_BASE }, { id: 'search', label: 'Search', icon: Search, href: protoRoutes.search }, { id: 'connectors', label: 'Connectors', icon: Integration, href: protoRoutes.connectors }, ] -/** Mock org rail: Home / Search / Connectors, then projects (each with its chats) and recent chats. */ +/** How the sidebar shows a chat's age: "5m", "2h", "3d". */ +function compactAge(date: Date): string { + const relative = formatRelativeTime(date.toISOString()) + return relative === 'just now' ? 'now' : relative.replace(' ago', '') +} + +/** One shape for a row, whether the chat is a real Sim chat or one from a pack. */ +interface ChatItem { + id: string + name: string + age: string + running: boolean +} + +function toChatItems( + project: Project, + real: MothershipChatMetadata[] | undefined, + fromSource: ProjectChat[] +): ChatItem[] { + if (project.isMock) + return fromSource.map((chat) => ({ + id: chat.id, + name: chat.title, + age: chat.age, + running: chat.running, + })) + return (real ?? []).map((chat) => ({ + id: chat.id, + name: chat.name, + age: compactAge(chat.updatedAt), + running: chat.isActive, + })) +} + +/** Org rail: Home / Search / Connectors, then the real workspaces as projects (each with its chats). */ export function ProtoSidebar() { const { isCollapsed: railCollapsed, isPeeking } = useSidebarChrome() const isCollapsed = railCollapsed && !isPeeking const pathname = usePathname() const [{ chat: openChat }] = useQueryStates(protoParsers) - const fullProject = WORKSPACES.find((w) => w.id === fullViewProject(pathname)) + const { projects, roots } = useProjects() + const { projects: orderedProjects, dragProps, isAnyDragActive } = useProjectOrder(roots) + const router = useRouter() + const [helpOpen, setHelpOpen] = useState(false) + const { data: organization } = useActiveOrganization() + const { data: session } = useSession() + /** Only a mock project has a full view here; a real project's Build is its workspace pages. */ + const fullProject = projects.find( + (project) => project.isMock && project.id === fullViewProject(pathname) + ) const inSettings = Boolean(fullProject && settingsProject(pathname)) + const organizationId = projects.find((project) => project.organizationId)?.organizationId + /** The organization's settings; without an organization, the viewer's workspace settings. */ + const accountSettingsHref = organizationId + ? organizationRoutes(organizationId).settingsSection('general') + : WORKSPACE_SETTINGS_PATH const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) const scrollContainerRef = useRef(null) const scrollContentRef = useRef(null) @@ -94,6 +144,8 @@ export function ProtoSidebar() { contentRef: scrollContentRef, enabled: !isCollapsed, }) + const organizationName = organization?.name ?? 'Organization' + const userName = session?.user?.name ?? 'You' return (
    @@ -182,13 +234,13 @@ export function ProtoSidebar() {
    {fullProject && inSettings ? ( ) : fullProject ? (
    - {WORKSPACES.map((workspace) => ( + {orderedProjects.map((project) => ( ))}
    - {GENERAL_CHATS.length > 0 && ( - -
    - {GENERAL_CHATS.map((chat) => ( - - ))} -
    -
    + /> )} )}
    -
    -
    - } - > - {PEOPLE.teddy.name} - -
    - {!isCollapsed && } -
    + router.push(accountSettingsHref)} + navigationLinks={[]} + onOpenDocs={() => window.open(DOCS_URL, '_blank', 'noopener,noreferrer')} + onJoinSlack={() => window.open(SLACK_COMMUNITY_URL, '_blank', 'noopener,noreferrer')} + onContactSupport={() => setHelpOpen(true)} + /> + ) } +interface OrgChatsProps { + organizationId: string + pathname: string | null + railCollapsed: boolean +} + +/** Chats that belong to no project: the organization's own Sim conversations. */ +function OrgChats({ organizationId, pathname, railCollapsed }: OrgChatsProps) { + const { data: chats = [] } = useOrganizationMothershipChats(organizationId) + if (!chats.length) return null + return ( + +
    + {chats.map((chat) => ( + + ))} +
    +
    + ) +} + interface ProjectTreeProps { - workspace: Workspace + project: Project pathname: string | null openChat: string railCollapsed: boolean + drag: ProjectDragProps + isAnyDragActive: boolean +} + +/** Which of the project's environments the path is in, if any. */ +function activeEnvironmentId(project: Project, pathname: string | null): string | null { + for (const environment of project.environments) { + const base = `${PROTO_BASE}/p/${environment.workspaceId}` + if (pathname === base || pathname?.startsWith(`${base}/`)) return environment.workspaceId + } + return null } /** * A project row with its chats listed underneath, like Codex's project → threads. - * Hovering the row reveals + for a new chat in that project. + * A project spans every environment in its fork lineage; the chats shown belong to the + * environment you are in. The row itself carries the workspace sidebar's ergonomics. */ -function ProjectTree({ workspace, pathname, openChat, railCollapsed }: ProjectTreeProps) { +function ProjectTree({ + project, + pathname, + openChat, + railCollapsed, + drag, + isAnyDragActive, +}: ProjectTreeProps) { const [showAll, setShowAll] = useState(false) - const base = `${PROTO_BASE}/p/${workspace.id}` - const inProject = pathname === base || Boolean(pathname?.startsWith(`${base}/`)) + const activeId = activeEnvironmentId(project, pathname) + const inProject = activeId !== null + const chatWorkspaceId = activeId ?? project.id /** null follows navigation (open while you're in the project); a click pins it open or shut. */ const [override, setOverride] = useState(null) const expanded = override ?? inProject - const chats = CHATS.filter((chat) => chat.workspaceId === workspace.id) + const sources = useProjectSources() + const { data: realChats } = useMothershipChats(project.isMock ? undefined : chatWorkspaceId) + const chats = toChatItems(project, realChats, sources.chatsFor(project)) + const needsYou = project.overlayPending ? 0 : sources.needsYouFor(project) const visible = showAll ? chats : chats.slice(0, CHAT_PREVIEW) const panelHref = (chatId: string) => - `${inProject && pathname ? pathname : protoRoutes.workspace(workspace.id)}?chat=${chatId}` + `${inProject && pathname ? pathname : protoRoutes.workspace(project.id)}?chat=${chatId}` return (
    - -
    - setOverride(inProject ? !expanded : true)} - aria-expanded={chats.length > 0 ? expanded : undefined} - className='flex min-w-0 flex-1 items-center gap-2' - > - - - - {!railCollapsed && ( - <> - {workspace.needsYou > 0 && ( - - {workspace.needsYou} - - )} - - - - - )} -
    -
    - {!railCollapsed && expanded && ( + 0 ? expanded : undefined} + needsYou={needsYou} + railCollapsed={railCollapsed} + drag={drag} + isAnyDragActive={isAnyDragActive} + onToggleExpand={() => setOverride(inProject ? !expanded : true)} + /> + {!railCollapsed && expanded && chats.length > 0 && (
    {visible.map((chat) => ( - {running ? ( + {chat.running ? ( ) : ( {chat.age} @@ -381,9 +448,7 @@ function ChatRow({ chat, href, active }: ChatRowProps) { } const PRIMARY_SECTIONS: readonly WorkspaceSection[] = MAIN_SECTION_IDS -const BUILD_SECTIONS: readonly WorkspaceSection[] = WORKSPACE_SECTIONS.map((s) => s.id).filter( - (id) => !MAIN_SECTION_IDS.includes(id) -) +const BUILD_SECTIONS: readonly WorkspaceSection[] = BUILD_SECTION_IDS function fullNavItems( workspaceId: string, @@ -398,20 +463,22 @@ function fullNavItems( } interface FullViewSectionsProps { - workspace: Workspace + project: Project pathname: string | null openChat: string railCollapsed: boolean } -/** Full view: the project's build sections, then its chats, in place of Projects and Recent chats. */ -function FullViewSections({ workspace, pathname, openChat, railCollapsed }: FullViewSectionsProps) { - const chats = CHATS.filter((chat) => chat.workspaceId === workspace.id) +/** Mock project full view: its static Build sections, then its chats, in place of Projects and Recent chats. */ +function FullViewSections({ project, pathname, openChat, railCollapsed }: FullViewSectionsProps) { + const sources = useProjectSources() + const { data: realChats } = useMothershipChats(project.isMock ? undefined : project.id) + const chats = toChatItems(project, realChats, sources.chatsFor(project)) return ( <>
    - {fullNavItems(workspace.id, BUILD_SECTIONS).map((item) => ( + {fullNavItems(project.id, BUILD_SECTIONS).map((item) => ( @@ -429,7 +496,7 @@ function FullViewSections({ workspace, pathname, openChat, railCollapsed }: Full ))} @@ -441,14 +508,17 @@ function FullViewSections({ workspace, pathname, openChat, railCollapsed }: Full } interface SettingsSectionsProps { - workspace: Workspace + project: Project pathname: string | null railCollapsed: boolean } -/** Settings: the project sidebar gives way to the settings list, grouped like prod. */ -function SettingsSections({ workspace, pathname, railCollapsed }: SettingsSectionsProps) { - const base = protoRoutes.settings(workspace.id) +/** + * Mock project settings: the project sidebar gives way to the settings list, grouped like prod. + * The project's own General page has content; the workspace sections have nothing behind them. + */ +function SettingsSections({ project, pathname, railCollapsed }: SettingsSectionsProps) { + const base = protoRoutes.settings(project.id) return ( <> {SETTINGS_GROUPS.map((group, index) => ( @@ -460,7 +530,7 @@ function SettingsSections({ workspace, pathname, railCollapsed }: SettingsSectio >
    {SETTINGS_NAV.filter((item) => item.group === group).map((item) => { - const href = protoRoutes.settings(workspace.id, item.id) + const href = protoRoutes.settings(project.id, item.id) const active = pathname === href || (item.id === SETTINGS_NAV[0].id && pathname === base.replace(/\/[^/]+$/, '')) diff --git a/apps/sim/app/playground/org/components/resource-section.tsx b/apps/sim/app/playground/org/components/resource-section.tsx index 6af1afe92d0..c388a0ece8a 100644 --- a/apps/sim/app/playground/org/components/resource-section.tsx +++ b/apps/sim/app/playground/org/components/resource-section.tsx @@ -1,9 +1,10 @@ 'use client' -import { useState } from 'react' +import { type ReactNode, useState } from 'react' import { Chip, ChipTag } from '@sim/emcn' import { Database, Files, Integration, Library, Plus, Table, Workflow } from '@sim/emcn/icons' -import { RESOURCES } from '@/app/playground/org/lib/mock-data' +import { RESOURCES } from '@/app/playground/org/fixtures' +import type { BuildSection } from '@/app/playground/org/lib/routes' import { timeCell } from '@/app/workspace/[workspaceId]/components/resource/components/time-cell' import { Resource, @@ -11,23 +12,22 @@ import { type ResourceRow, } from '@/app/workspace/[workspaceId]/components/resource/resource' -type ResourceSectionId = keyof typeof RESOURCES +export type ResourceSectionId = Exclude -const CONFIG: Record< - ResourceSectionId, - { - title: string - icon: React.ComponentType<{ className?: string }> - create?: string - meta: string - } -> = { - workflows: { title: 'Workflows', icon: Workflow, create: 'New workflow', meta: 'Status' }, - logs: { title: 'Logs', icon: Library, meta: 'Result' }, - tables: { title: 'Tables', icon: Table, create: 'New table', meta: 'Rows' }, - files: { title: 'Files', icon: Files, create: 'Upload', meta: 'Type' }, - knowledge: { title: 'Knowledge', icon: Database, create: 'New base', meta: 'Documents' }, - credentials: { title: 'Integrations', icon: Integration, create: 'Connect', meta: 'Status' }, +interface SectionConfig { + title: string + icon: React.ComponentType<{ className?: string }> + /** Label of the create chip; a mock project's chip opens nothing. */ + create?: string +} + +const CONFIG: Record = { + workflows: { title: 'Workflows', icon: Workflow, create: 'New workflow' }, + logs: { title: 'Logs', icon: Library }, + tables: { title: 'Tables', icon: Table, create: 'New table' }, + files: { title: 'Files', icon: Files, create: 'Upload' }, + knowledge: { title: 'Knowledge', icon: Database, create: 'New base' }, + credentials: { title: 'Integrations', icon: Integration, create: 'Connect' }, } const COLUMNS: ResourceColumn[] = [ @@ -37,11 +37,19 @@ const COLUMNS: ResourceColumn[] = [ { id: 'updated', header: 'Updated' }, ] -/** The existing Resource list over static rows; the section chip is the title, so no header bar. */ -export function ResourceSection({ section }: { section: ResourceSectionId }) { +interface ResourceSectionProps { + section: ResourceSectionId +} + +/** + * A mock project's Build section: the static rows of its pack in the workspace Resource list. + * A real project's Build sections are its workspace pages, so nothing real renders here. + */ +export function ResourceSection({ section }: ResourceSectionProps) { const config = CONFIG[section] const [search, setSearch] = useState('') const Icon = config.icon + const rows: ResourceRow[] = RESOURCES[section] .filter((item) => item.name.toLowerCase().includes(search.toLowerCase())) .map((item) => ({ @@ -55,6 +63,7 @@ export function ResourceSection({ section }: { section: ResourceSectionId }) { updated: timeCell(item.updated), }, })) + return ( - + {rows.length === 0 ? ( + No {config.title.toLowerCase()} yet. + ) : ( + + )} ) } + +function Empty({ children }: { children: ReactNode }) { + return

    {children}

    +} diff --git a/apps/sim/app/playground/org/components/runs-dashboard.tsx b/apps/sim/app/playground/org/components/runs-dashboard.tsx new file mode 100644 index 00000000000..1c685e7eaf0 --- /dev/null +++ b/apps/sim/app/playground/org/components/runs-dashboard.tsx @@ -0,0 +1,194 @@ +'use client' + +import { type ReactNode, useMemo, useState } from 'react' +import { Chip, DashboardMetric, LineChart, Loader } from '@sim/emcn' +import Link from 'next/link' +import type { Project } from '@/app/playground/org/lib/project' +import { workspaceRoutes } from '@/app/playground/org/lib/routes' +import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' +import { useDashboardStats } from '@/hooks/queries/logs' +import type { TimeRange } from '@/stores/logs/filters/types' + +const RANGES: { id: TimeRange; label: string }[] = [ + { id: 'Past 24 hours', label: '24h' }, + { id: 'Past 7 days', label: '7d' }, + { id: 'Past 30 days', label: '30d' }, +] + +interface RunsDashboardProps { + project: Project + title: ReactNode +} + +/** Runs, errors and latency for every workflow in the project, from the workspace's execution stats. */ +export function RunsDashboard({ project, title }: RunsDashboardProps) { + const [range, setRange] = useState('Past 7 days') + const { data, isPending, error } = useDashboardStats(project.id, { + timeRange: range, + level: 'all', + workflowIds: [], + folderIds: [], + triggers: [], + searchQuery: '', + }) + + const series = useMemo(() => { + const segments = data?.aggregateSegments ?? [] + return { + runs: segments.map((segment) => ({ + timestamp: segment.timestamp, + value: segment.totalExecutions, + })), + errors: segments.map((segment) => ({ + timestamp: segment.timestamp, + value: segment.totalExecutions - segment.successfulExecutions, + })), + } + }, [data]) + + const successRate = + data && data.totalRuns > 0 + ? Math.round(((data.totalRuns - data.totalErrors) / data.totalRuns) * 100) + : null + const workflows = [...(data?.workflows ?? [])].sort( + (a, b) => b.totalExecutions - a.totalExecutions + ) + + return ( +
    +
    +
    + {title} +
    + {RANGES.map((option) => ( + setRange(option.id)} + > + {option.label} + + ))} +
    +
    + + {error ? ( +

    {error.message}

    + ) : ( + <> +
    + + + + +
    + +
    + + + + + + +
    + +
    +
    + Workflows + {workflows.length} +
    + {isPending ? ( +
    + +
    + ) : workflows.length === 0 ? ( +

    + No runs in this period. +

    + ) : ( +
      + {workflows.map((workflow) => ( +
    • + + {workflow.workflowName} + + + {workflow.totalExecutions.toLocaleString()} runs + + + {(workflow.totalExecutions - workflow.totalSuccessful).toLocaleString()}{' '} + errors + + + {Math.round(workflow.overallSuccessRate)}% + +
    • + ))} +
    + )} +
    + + )} +
    +
    + ) +} + +interface TileProps { + label: string + value: string | number + unit?: string + loading: boolean +} + +function Tile({ label, value, unit, loading }: TileProps) { + return ( +
    + +
    + ) +} + +interface ChartCardProps { + label: string + count?: number + loading: boolean + children: ReactNode +} + +function ChartCard({ label, count, loading, children }: ChartCardProps) { + return ( +
    +
    + {label} + {count !== undefined && ( + {count.toLocaleString()} + )} +
    +
    + {loading ? ( +
    + +
    + ) : ( + children + )} +
    +
    + ) +} diff --git a/apps/sim/app/playground/org/components/workspace-view.tsx b/apps/sim/app/playground/org/components/workspace-view.tsx index 04d96a471f3..53cc600d931 100644 --- a/apps/sim/app/playground/org/components/workspace-view.tsx +++ b/apps/sim/app/playground/org/components/workspace-view.tsx @@ -3,32 +3,36 @@ import { ChipLink } from '@sim/emcn' import { Expand } from '@sim/emcn/icons' import { Changelog } from '@/app/playground/org/components/changelog' +import { EnvironmentSwitcher } from '@/app/playground/org/components/environment-switcher' +import { EnvironmentsTab } from '@/app/playground/org/components/environments/environments-tab' import { IssuesList } from '@/app/playground/org/components/issues-list' import { ProjectSettings } from '@/app/playground/org/components/project-settings' import { ProtoDashboard } from '@/app/playground/org/components/proto-dashboard' import { ResourceSection } from '@/app/playground/org/components/resource-section' -import type { Workspace } from '@/app/playground/org/lib/mock-data' +import type { Project } from '@/app/playground/org/lib/project' import { + buildSectionHref, MAIN_SECTIONS, protoRoutes, WORKSPACE_SECTIONS, type WorkspaceSection, } from '@/app/playground/org/lib/routes' import { DEFAULT_SETTINGS_SECTION } from '@/app/playground/org/lib/settings-nav' +import { useProjectDescription } from '@/app/playground/org/lib/use-project-resources' interface WorkspaceViewProps { - workspace: Workspace + project: Project section: WorkspaceSection - /** Full view: the sidebar carries navigation, so the page drops the chip row. */ + /** Full view (mock projects only): the sidebar carries navigation, so the page drops the chip row. */ full: boolean settingsSection?: string } /** - * Main view: dashboard, changelog, and issues as chips, with a button into the full view. - * Full view: just the section; the sidebar has switched to this project's navigation. + * Main view: dashboard, changelog, issues and environments as chips, with a button into Build. + * Build opens the real workspace pages; a mock project shows its static tables here instead. */ -export function WorkspaceView({ workspace, section, full, settingsSection }: WorkspaceViewProps) { +export function WorkspaceView({ project, section, full, settingsSection }: WorkspaceViewProps) { const current = WORKSPACE_SECTIONS.find((item) => item.id === section) if (!current) throw new Error(`Unknown section ${section}`) return ( @@ -36,24 +40,28 @@ export function WorkspaceView({ workspace, section, full, settingsSection }: Wor {full && section === 'settings' ? null : full ? (
    -

    {current.label}

    +

    {current.label}

    +
    ) : (

    - {workspace.name} + {project.name}

    -

    {workspace.description}

    + +
    +
    + + + Open full view +
    - - Open full view -
    )}
    - +
    ) } +function ProjectDescription({ project }: { project: Project }) { + const text = useProjectDescription(project) + return

    {text}

    +} + interface SectionBodyProps { - workspace: Workspace + project: Project section: WorkspaceSection settingsSection?: string } -function SectionBody({ workspace, section, settingsSection }: SectionBodyProps) { +function SectionBody({ project, section, settingsSection }: SectionBodyProps) { switch (section) { case 'dashboard': return (
    - +
    ) case 'changelog': - return + return case 'issues': - return + return + case 'environments': + return case 'settings': return ( ) diff --git a/apps/sim/app/playground/org/connectors/page.tsx b/apps/sim/app/playground/org/connectors/page.tsx new file mode 100644 index 00000000000..1f5bf322982 --- /dev/null +++ b/apps/sim/app/playground/org/connectors/page.tsx @@ -0,0 +1,52 @@ +import { Suspense } from 'react' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getActiveOrganizationId } from '@/lib/auth/session-response' +import { + getOrganizationSurfaceContext, + resolveOrganizationLanding, +} from '@/lib/organizations/surface' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' +import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations' +import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider' +import { protoRoutes } from '@/app/playground/org/lib/routes' + +/** + * Connectors: the organization's own integrations page (the viewer's connected accounts), + * loaded and rendered the way `/o/[organizationId]/integrations` does, inside the org rail. + */ +export default async function ConnectorsPage() { + const session = await getSession() + if (!session?.user) + redirect( + buildAuthCrossLink('/login', { callbackUrl: protoRoutes.connectors, isInviteFlow: false }) + ) + /** The session's active organization, else the first the viewer belongs to, as the app entry does. */ + const organizationId = await resolveOrganizationLanding( + session.user.id, + getActiveOrganizationId(session) + ) + const context = organizationId + ? await getOrganizationSurfaceContext(organizationId, session.user.id) + : null + if (!context?.searchAccess.memberScoped) + return ( +
    +

    Connectors

    +

    + {organizationId + ? 'Sim Search is not enabled for this organization.' + : 'You are not a member of an organization.'} +

    +
    + ) + return ( + + Loading connectors…

    } + > + +
    +
    + ) +} diff --git a/apps/sim/app/playground/org/lib/changelog-data.ts b/apps/sim/app/playground/org/fixtures/changelog-data.ts similarity index 84% rename from apps/sim/app/playground/org/lib/changelog-data.ts rename to apps/sim/app/playground/org/fixtures/changelog-data.ts index a14ba3b0f34..b6899fcb091 100644 --- a/apps/sim/app/playground/org/lib/changelog-data.ts +++ b/apps/sim/app/playground/org/fixtures/changelog-data.ts @@ -1,82 +1,8 @@ -export interface ChangelogMetric { - label: string - before: number - after: number - unit?: string - /** Lower is better for this metric (drives the delta color). */ - lowerIsBetter?: boolean -} - -export interface ChangelogChart { - title: string - kind: 'bar' | 'line' - unit?: string - /** Daily values; the release day is `releaseIndex`. */ - values: number[] - releaseIndex: number -} - -/** A chat is where a person and their Sim made the change. */ -export interface ChangeChat { - id: string - owner: string -} - -export interface Change { - text: string - issues: string[] - chat?: ChangeChat - workflow?: string -} - -export interface ChangelogEntry { - id: string - workspaceId: string - date: string - version: string - workflows: string[] - title: string - summary: string - changes: Change[] - metrics: ChangelogMetric[] - chart?: ChangelogChart - reporters: string[] - channel: string - /** The newest shipped release streams a live "since release" counter. */ - live?: { label: string; goodLabel: string } -} - -export interface DraftChange extends Change { - workflow: string - /** `approval` changes are held until their issue is approved. */ - state: 'ready' | 'approval' -} - -export interface RunningWork { - issue: string - agent: string - task: string - step: string - /** What the agent says it's doing, newest last; the card cycles through these. */ - narration: string[] - progress: number - startedMinutesAgo: number - chat: ChangeChat -} - -export interface DraftRelease { - workspaceId: string - versions: { workflow: string; from: string; to: string }[] - changes: DraftChange[] - running: RunningWork[] - chart: ChangelogChart - /** Chart marker for the pending release. */ - marker: string - /** What the shipped entry says once Release is clicked. */ - release: { title: string; summary: string; reporters: string[]; channel: string } -} +import { CLAIMS_CHANGELOG, CLAIMS_DRAFT } from '@/app/playground/org/fixtures/claims-data' +import type { ChangelogEntry, DraftRelease } from '@/app/playground/org/lib/types' export const DRAFTS: DraftRelease[] = [ + CLAIMS_DRAFT, { workspaceId: 'infra', versions: [ @@ -277,6 +203,7 @@ export const DRAFTS: DraftRelease[] = [ ] export const CHANGELOG: ChangelogEntry[] = [ + ...CLAIMS_CHANGELOG, { id: 'kb-resync', workspaceId: 'infra', @@ -485,20 +412,3 @@ export const CHANGELOG: ChangelogEntry[] = [ channel: '#product-feedback', }, ] - -/** Chats that produced or are running work on an issue, newest role first. */ -export function chatsForIssue(issueKey: string): (ChangeChat & { running: boolean })[] { - const found = new Map() - for (const draft of DRAFTS) { - for (const work of draft.running) - if (work.issue === issueKey) found.set(work.chat.id, { ...work.chat, running: true }) - for (const change of draft.changes) - if (change.chat && change.issues.includes(issueKey) && !found.has(change.chat.id)) - found.set(change.chat.id, { ...change.chat, running: false }) - } - for (const entry of CHANGELOG) - for (const change of entry.changes) - if (change.chat && change.issues.includes(issueKey) && !found.has(change.chat.id)) - found.set(change.chat.id, { ...change.chat, running: false }) - return [...found.values()] -} diff --git a/apps/sim/app/playground/org/fixtures/claims-data.ts b/apps/sim/app/playground/org/fixtures/claims-data.ts new file mode 100644 index 00000000000..3ba15260b73 --- /dev/null +++ b/apps/sim/app/playground/org/fixtures/claims-data.ts @@ -0,0 +1,479 @@ +import type { + ChangelogEntry, + Chat, + DraftRelease, + Issue, + PlanStep, + Release, + Report, + TriageProposal, +} from '@/app/playground/org/lib/types' + +/** + * Overlay pack for a payment-variance investigation agent: it queries a data warehouse for + * payment exceptions and provider-service backlog, drafts investigation briefs, and routes + * cases to an analyst for approval. + */ + +const WORKFLOWS = { + investigation: 'Payment Exception Investigation', + recovery: 'Provider Service Recovery', + review: 'Analyst Review - Demo Cases', +} as const + +const OWNERS = { + teddy: { id: 'teddy', name: 'Teddy Li' }, + ava: { id: 'ava', name: 'Ava Chen' }, + marcus: { id: 'marcus', name: 'Marcus Reid' }, + priya: { id: 'priya', name: 'Priya Shah' }, +} as const + +const slack = (author: string, text: string, at: string): Report => ({ + source: 'slack', + channel: '#claims-analysts', + author, + text, + at, +}) + +const done = (label: string): PlanStep => ({ label, status: 'completed' }) +const doing = (label: string): PlanStep => ({ label, status: 'inProgress' }) +const todo = (label: string): PlanStep => ({ label, status: 'pending' }) + +export const CLAIMS_CHATS: Chat[] = [ + { + id: 'c20', + age: '4m', + title: 'Why does Analyst Review reject “escalate”?', + workspaceId: 'claims', + owner: OWNERS.marcus.name, + }, + { + id: 'c21', + age: '25m', + title: 'Name the variance driver in every brief', + workspaceId: 'claims', + owner: OWNERS.ava.name, + }, + { + id: 'c22', + age: '1h', + title: 'Flag provider backlog before it breaches SLA', + workspaceId: 'claims', + owner: OWNERS.priya.name, + }, + { + id: 'c23', + age: '2d', + title: 'Cite the SQL behind each number', + workspaceId: 'claims', + owner: OWNERS.teddy.name, + }, +] + +export const CLAIMS_RELEASES: Release[] = [ + { + id: 'r-claims-inv-v2', + workspaceId: 'claims', + name: 'Payment Exception Investigation v2', + date: 'Sep 26', + notes: 'Briefs cite the SQL behind each number; warehouse queries no longer time out.', + notified: 3, + }, + { + id: 'r-claims-rec-v1', + workspaceId: 'claims', + name: 'Provider Service Recovery v1', + date: 'Sep 19', + notes: 'Nightly backlog recovery run with a per-payer exception threshold.', + notified: 2, + }, +] + +export const CLAIMS_TRIAGE: TriageProposal[] = [ + { + id: 't20', + workspaceId: 'claims', + title: 'Briefs list a claim twice when it has two payers', + summary: + 'Secondary-payer claims join once per payer row. Reproduced on the Sep 27 brief: 14 of 212 lines are duplicates.', + reports: [ + slack('Dana (analyst)', 'the brief counted claim 88-1042 twice again', '1h ago'), + slack('Rob (analyst)', 'duplicate lines on secondary payer claims', '3h ago'), + ], + suggestion: { kind: 'new' }, + at: '1h ago', + }, + { + id: 't21', + workspaceId: 'claims', + title: 'Recovery run skipped the west region last night', + summary: 'Same symptom as PAY-208: the region loop stops when a region returns zero backlog.', + reports: [slack('Dana (analyst)', 'no west region cases in this morning’s brief', '4h ago')], + suggestion: { kind: 'duplicate', key: 'PAY-208', confidence: 76 }, + at: '4h ago', + }, +] + +export const CLAIMS_ISSUES: Issue[] = [ + { + key: 'PAY-212', + workspaceId: 'claims', + title: 'Analyst Review fails on decisions other than approve or close', + status: 'blocked', + priority: 1, + owner: OWNERS.marcus, + delegate: { kind: 'workflow', name: WORKFLOWS.review }, + agent: { state: 'error', label: 'Failed 3× · invalid decision' }, + project: 'Case review', + labels: ['bug'], + description: + 'Analysts type “escalate” or “needs data” in the review form. The workflow only handles approve and close, so the run fails and the case stays open.', + research: { + cause: 'The decision switch has two branches; every other value falls through to an error.', + related: ['PAY-197', 'run 4c1e…'], + }, + reports: [ + slack('Dana (analyst)', 'my escalate decision errored out three times', '2h ago'), + slack('Rob (analyst)', 'review form fails unless I pick approve', '1d ago'), + ], + plan: [ + done('Reproduce with a “needs data” decision'), + { label: 'Add escalate and needs-data branches', status: 'pending' }, + todo('Reject anything else with a reason'), + ], + activity: [ + { kind: 'action', text: 'Reproduced with decision = escalate on case 88-1042', at: '1h ago' }, + { + kind: 'error', + text: 'Run failed: invalid decision "escalate" — waiting for the allowed list', + at: '40m ago', + }, + ], + resources: [{ kind: 'workflow', name: WORKFLOWS.review }], + linked: [ + { + system: 'jira', + key: 'CLM-1042', + title: 'Review decisions beyond approve/close', + status: 'In Progress', + }, + ], + }, + { + key: 'PAY-208', + workspaceId: 'claims', + title: 'Flag provider backlog older than 14 days before it breaches SLA', + status: 'in_progress', + priority: 2, + owner: OWNERS.priya, + delegate: { kind: 'workflow', name: WORKFLOWS.recovery }, + agent: { state: 'active', label: 'Scanning region 3/7' }, + project: 'Backlog recovery', + labels: ['agent'], + due: 'Oct 2', + description: + 'Score every open provider-service case by age and payer, and put anything older than 14 days at the top of the nightly recovery brief.', + reports: [ + slack('Dana (analyst)', 'we found out about the SLA breach from the payer', '2d ago'), + ], + plan: [ + done('Pull open cases by region from the warehouse'), + doing('Score each case by age and payer'), + todo('Write the flagged cases into the recovery brief'), + todo('Post the top 20 to #claims-analysts'), + ], + activity: [ + { kind: 'action', text: 'Queried open cases — 1,284 rows across 7 regions', at: '9m ago' }, + { + kind: 'thought', + text: 'The west region returned zero rows; checking whether the loop stops early.', + at: '5m ago', + }, + { kind: 'action', text: 'Scoring region 3/7…', at: 'now' }, + ], + resources: [{ kind: 'workflow', name: WORKFLOWS.recovery }], + }, + { + key: 'PAY-215', + workspaceId: 'claims', + title: 'Name the top variance driver in every brief', + status: 'in_progress', + priority: 3, + owner: OWNERS.ava, + delegate: { kind: 'sim', name: 'Sim' }, + agent: { state: 'awaitingInput', label: 'Needs your approval' }, + project: 'Brief quality', + labels: [], + description: + 'Each investigation brief should open with the single biggest driver of the variance (payer, procedure, or provider) and the share it explains.', + reports: [ + slack('Rob (analyst)', 'I read the whole brief to find out it was one payer', '3d ago'), + ], + plan: [ + done('Rank drivers by explained variance'), + doing('Draft the opening sentence'), + todo('Update the investigation playbook'), + ], + activity: [ + { + kind: 'response', + text: 'Drafted the opening line on 5 briefs. Two briefs have a tie between payer and procedure — which should lead?', + at: '25m ago', + }, + { + kind: 'elicitation', + text: 'When two drivers tie, lead with the payer or the procedure?', + at: '25m ago', + }, + ], + resources: [ + { kind: 'workflow', name: WORKFLOWS.investigation }, + { kind: 'file', name: 'Payment variance playbook' }, + ], + }, + { + key: 'PAY-219', + workspaceId: 'claims', + title: 'Weekly digest of open cases to the analyst channel', + status: 'todo', + priority: 3, + owner: OWNERS.teddy, + labels: [], + description: 'Every Monday, post the open cases by age with the analyst who owns each one.', + reports: [], + plan: [], + activity: [], + resources: [], + }, + { + key: 'PAY-221', + workspaceId: 'claims', + title: 'Add denial-code lookups as a second warehouse space', + status: 'backlog', + priority: 4, + owner: OWNERS.marcus, + labels: ['feature request'], + description: + 'Briefs should explain denial codes in plain language from the payer reference tables.', + reports: [ + slack('Dana (analyst)', 'would love the denial code spelled out in the brief', '1w ago'), + ], + plan: [], + activity: [], + resources: [], + }, + { + key: 'PAY-197', + workspaceId: 'claims', + title: 'Briefs cite the SQL behind each number', + status: 'done', + priority: 2, + owner: OWNERS.teddy, + delegate: { kind: 'agent', name: WORKFLOWS.investigation }, + agent: { state: 'complete', label: 'Shipped' }, + project: 'Brief quality', + labels: [], + description: + 'Analysts sent briefs back because they could not see where a number came from. Every figure now links to the query that produced it.', + research: { + cause: 'The brief template dropped the query text after the LLM summarised it.', + related: ['run 9a2f…'], + }, + reports: [slack('Rob (analyst)', 'where does the 12% come from?', '9d ago')], + plan: [ + done('Keep the query with each figure'), + done('Render the query as a footnote'), + done('Deploy'), + ], + activity: [ + { + kind: 'response', + text: 'Shipped in Payment Exception Investigation v2. Replied to 2 reporters.', + at: 'Sep 26', + }, + ], + releaseId: 'r-claims-inv-v2', + resources: [{ kind: 'workflow', name: WORKFLOWS.investigation }], + }, + { + key: 'PAY-201', + workspaceId: 'claims', + title: 'Warehouse queries time out on the claims fact table', + status: 'done', + priority: 1, + owner: OWNERS.marcus, + delegate: { kind: 'workflow', name: WORKFLOWS.investigation }, + labels: ['bug'], + description: '', + reports: [slack('Dana (analyst)', 'brief never arrived this morning', '8d ago')], + plan: [], + activity: [], + releaseId: 'r-claims-inv-v2', + resources: [], + }, + { + key: 'PAY-190', + workspaceId: 'claims', + title: 'Payment exception threshold is configurable per payer', + status: 'done', + priority: 3, + owner: OWNERS.priya, + labels: [], + description: '', + reports: [], + plan: [], + activity: [], + releaseId: 'r-claims-rec-v1', + resources: [], + }, +] + +export const CLAIMS_DRAFT: DraftRelease = { + workspaceId: 'claims', + versions: [ + { workflow: WORKFLOWS.review, from: 'v1', to: 'v2' }, + { workflow: WORKFLOWS.investigation, from: 'v2', to: 'v3' }, + ], + changes: [ + { + text: 'Escalate and needs-data are valid review decisions; anything else is rejected with a reason', + issues: ['PAY-212'], + chat: { id: 'c20', owner: OWNERS.marcus.name }, + workflow: WORKFLOWS.review, + state: 'approval', + }, + { + text: 'Every brief opens with the top variance driver and the share it explains', + issues: ['PAY-215'], + chat: { id: 'c21', owner: OWNERS.ava.name }, + workflow: WORKFLOWS.investigation, + state: 'ready', + }, + { + text: 'Secondary-payer claims appear once in the brief', + issues: ['PAY-215'], + chat: { id: 'c21', owner: OWNERS.ava.name }, + workflow: WORKFLOWS.investigation, + state: 'ready', + }, + ], + running: [ + { + issue: 'PAY-208', + agent: WORKFLOWS.recovery, + task: 'Flagging provider backlog older than 14 days', + step: 'Scoring open cases in region 3 of 7', + narration: [ + 'I pulled 1,284 open provider-service cases across 7 regions from the warehouse.', + 'The west region returned zero rows, which looks like the loop stopping early rather than an empty backlog.', + 'I’ll re-run the west region on its own before scoring, so the brief does not miss it again.', + ], + progress: 0.4, + startedMinutesAgo: 9, + chat: { id: 'c22', owner: OWNERS.priya.name }, + }, + { + issue: 'PAY-215', + agent: 'Sim', + task: 'Drafting the opening line for each brief', + step: 'Ranking drivers on the 5 open briefs', + narration: [ + 'I ranked payer, procedure and provider by the variance each explains on the 5 open briefs.', + 'Three briefs have one clear driver; two are a tie between a payer and a procedure.', + 'I’ve asked Ava which should lead when they tie, then I’ll update the playbook.', + ], + progress: 0.7, + startedMinutesAgo: 25, + chat: { id: 'c21', owner: OWNERS.ava.name }, + }, + ], + chart: { + title: 'Cases sent back by analysts', + kind: 'bar', + values: [9, 8, 11, 7, 10, 9, 8, 6, 5, 6, 4, 3, 3, 2], + releaseIndex: 13, + }, + marker: 'v3', + release: { + title: 'Review decisions and clearer briefs', + summary: + 'Analysts can escalate or ask for data from the review form, and every brief leads with the driver that explains the variance.', + reporters: ['Dana (analyst)', 'Rob (analyst)'], + channel: '#claims-analysts', + }, +} + +export const CLAIMS_CHANGELOG: ChangelogEntry[] = [ + { + id: 'claims-inv-v2', + workspaceId: 'claims', + date: 'Sep 26', + version: 'v2', + workflows: [WORKFLOWS.investigation], + title: 'Briefs cite the SQL behind each number', + summary: + 'Analysts were sending briefs back because a figure had no source. Every number in a brief now footnotes the warehouse query that produced it, and the claims fact table query finishes inside the timeout.', + changes: [ + { + text: 'Each figure keeps the query that produced it and renders it as a footnote', + issues: ['PAY-197'], + chat: { id: 'c23', owner: OWNERS.teddy.name }, + workflow: WORKFLOWS.investigation, + }, + { + text: 'Claims fact table queries filter by service month before joining payers', + issues: ['PAY-201'], + workflow: WORKFLOWS.investigation, + }, + ], + metrics: [ + { + label: 'Briefs sent back for missing evidence', + before: 31, + after: 4, + unit: '%', + lowerIsBetter: true, + }, + { label: 'Brief delivery time', before: 41, after: 6, unit: 'min', lowerIsBetter: true }, + ], + chart: { + title: 'Briefs sent back for missing evidence', + kind: 'line', + unit: '%', + values: [28, 30, 33, 29, 31, 34, 30, 32, 31, 30, 5, 4, 3, 4], + releaseIndex: 10, + }, + reporters: ['Rob (analyst)', 'Dana (analyst)'], + channel: '#claims-analysts', + live: { label: 'briefs since release', goodLabel: 'sent back' }, + }, + { + id: 'claims-rec-v1', + workspaceId: 'claims', + date: 'Sep 19', + version: 'v1', + workflows: [WORKFLOWS.recovery], + title: 'Provider backlog recovery runs nightly', + summary: + 'Recovery cases used to be opened by hand from a weekly export. The agent now pulls the backlog every night, applies each payer’s exception threshold, and opens the cases itself.', + changes: [ + { + text: 'Nightly run over the provider-service backlog', + issues: ['PAY-190'], + workflow: WORKFLOWS.recovery, + }, + { + text: 'Exception threshold is read per payer from the settings table', + issues: ['PAY-190'], + workflow: WORKFLOWS.recovery, + }, + ], + metrics: [ + { label: 'Cases opened by hand each week', before: 26, after: 3, lowerIsBetter: true }, + { label: 'Days from breach to case', before: 9, after: 1, lowerIsBetter: true }, + ], + reporters: ['Dana (analyst)'], + channel: '#claims-analysts', + }, +] diff --git a/apps/sim/app/playground/org/fixtures/index.ts b/apps/sim/app/playground/org/fixtures/index.ts new file mode 100644 index 00000000000..823a445e0b0 --- /dev/null +++ b/apps/sim/app/playground/org/fixtures/index.ts @@ -0,0 +1,14 @@ +export { CHANGELOG, DRAFTS } from '@/app/playground/org/fixtures/changelog-data' +export { mockTableAnalytics } from '@/app/playground/org/fixtures/mock-analytics' +export { + CHATS, + ISSUES, + ORGANIZATION, + PEOPLE, + RELEASES, + RESOURCES, + TRIAGE, + WORKSPACES, +} from '@/app/playground/org/fixtures/mock-data' +export { matchOverlay, NO_PACK } from '@/app/playground/org/fixtures/overlays' +export { fixtureProjectSources } from '@/app/playground/org/fixtures/project-sources' diff --git a/apps/sim/app/playground/org/lib/infra-data.ts b/apps/sim/app/playground/org/fixtures/infra-data.ts similarity index 85% rename from apps/sim/app/playground/org/lib/infra-data.ts rename to apps/sim/app/playground/org/fixtures/infra-data.ts index 41295381b68..6a7232c1a04 100644 --- a/apps/sim/app/playground/org/lib/infra-data.ts +++ b/apps/sim/app/playground/org/fixtures/infra-data.ts @@ -1,104 +1,4 @@ -import type { Issue, Release, TriageProposal } from '@/app/playground/org/lib/mock-data' - -export interface RunBlock { - id: string - name: string - type: string - /** Offset from the run start, in ms. */ - start: number - duration: number - depth: number - output?: string - highlight?: boolean -} - -export type Evidence = - | { type: 'run'; runId: string; summary: string; blocks: RunBlock[] } - | { type: 'runs'; title: string; columns: string[]; rows: string[][]; flagged: number[] } - | { - type: 'diff' - title: string - subtitle: string - lines: { kind: 'add' | 'del' | 'ctx'; text: string }[] - } - -export interface InvestigationStep { - at: string - kind: 'feedback' | 'check' | 'workflow' | 'runs' | 'run' | 'config' | 'history' | 'conclusion' - title: string - detail?: string - evidence?: Evidence -} - -export interface SourceMessage { - author: string - channel: string - at: string - text: string -} - -export type Origin = - | { kind: 'slack'; message: SourceMessage; thread: SourceMessage[] } - | { - kind: 'kbQuery' - knowledgeBase: string - query: string - at: string - detail: string - answers: { answer: string; cites: string }[] - } - -export interface SourceDoc { - system: 'confluence' - title: string - space: string - author: string - updated: string - /** Paragraphs from the page; `highlight` marks the sentence that conflicts. */ - excerpt: string[] - highlight: string -} - -export interface InvestigationSource { - kind: 'slack' | 'workflow' | 'logs' | 'history' - label: string -} - -export interface Investigation { - workflow: { name: string; version: string; nextVersion: string } - summary: { label: string; text: string }[] - /** What started the investigation: a person in Slack, or Sim's own knowledge check. */ - origin: Origin - /** Source documents that disagree, when the issue is about knowledge content. */ - documents?: SourceDoc[] - sources: InvestigationSource[] - timeline: InvestigationStep[] - rootCause: { - summary: string - chain: string[] - confidence: 'High' | 'Medium' | 'Low' - confidenceReason: string - block: { path: string; lines: string[] } - } - frequency: { - query: string - days: { date: string; count: number }[] - stats: { label: string; value: string }[] - } - proposal: { - summary: string - diff: { kind: 'add' | 'del' | 'ctx'; text: string }[] - tradeoff: string - replay: string - /** A document edit handed to a Sim chat for approval, instead of a workflow redeploy. */ - docEdit?: { - system: 'confluence' - page: string - space: string - lines: { kind: 'add' | 'del' | 'ctx'; text: string }[] - } - } -} +import type { Investigation, Issue, Release, TriageProposal } from '@/app/playground/org/lib/types' const MARCUS = { id: 'marcus', name: 'Marcus Reid' } const TEDDY = { id: 'teddy', name: 'Teddy Li' } diff --git a/apps/sim/app/playground/org/lib/mock-analytics.ts b/apps/sim/app/playground/org/fixtures/mock-analytics.ts similarity index 100% rename from apps/sim/app/playground/org/lib/mock-analytics.ts rename to apps/sim/app/playground/org/fixtures/mock-analytics.ts diff --git a/apps/sim/app/playground/org/lib/mock-data.ts b/apps/sim/app/playground/org/fixtures/mock-data.ts similarity index 84% rename from apps/sim/app/playground/org/lib/mock-data.ts rename to apps/sim/app/playground/org/fixtures/mock-data.ts index a7ec8d2721a..93511046e02 100644 --- a/apps/sim/app/playground/org/lib/mock-data.ts +++ b/apps/sim/app/playground/org/fixtures/mock-data.ts @@ -1,115 +1,28 @@ +import { + CLAIMS_CHATS, + CLAIMS_ISSUES, + CLAIMS_RELEASES, + CLAIMS_TRIAGE, +} from '@/app/playground/org/fixtures/claims-data' import { INFRA_ISSUES, INFRA_RELEASES, INFRA_TRIAGE, - type Investigation, -} from '@/app/playground/org/lib/infra-data' -import { SUPPORT_ISSUES } from '@/app/playground/org/lib/support-data' - -export type IssueStatus = 'backlog' | 'todo' | 'in_progress' | 'blocked' | 'done' | 'canceled' -export type Priority = 0 | 1 | 2 | 3 | 4 -export type AgentState = 'pending' | 'active' | 'awaitingInput' | 'error' | 'complete' | 'stale' -export type FeedbackSource = 'slack' | 'email' | 'intercom' | 'pagerduty' -export type TrackerKind = 'sim' | 'linear' | 'jira' - -export interface Person { - id: string - name: string -} - -export interface Delegate { - kind: 'agent' | 'workflow' | 'sim' - name: string -} - -export interface Report { - source: FeedbackSource - channel: string - author: string - text: string - at: string -} - -export interface PlanStep { - label: string - status: 'pending' | 'inProgress' | 'completed' | 'canceled' -} - -export interface Activity { - kind: 'thought' | 'action' | 'elicitation' | 'response' | 'error' - text: string - at: string -} - -/** A ticket in Linear, Jira, or GitHub that a Sim issue points at. */ -export interface LinkedTicket { - system: 'linear' | 'jira' | 'github' - key: string - title: string - status: string -} - -export interface Issue { - key: string - workspaceId: string - title: string - status: IssueStatus - priority: Priority - owner: Person - delegate?: Delegate - agent?: { state: AgentState; label: string } - project?: string - labels: string[] - due?: string - description: string - research?: { cause: string; related: string[] } - reports: Report[] - plan: PlanStep[] - activity: Activity[] - releaseId?: string - resources: { kind: 'table' | 'workflow' | 'dashboard' | 'knowledge' | 'file'; name: string }[] - investigation?: Investigation - /** Tickets in external trackers this Sim issue covers; derived from the project tracker when omitted. */ - linked?: LinkedTicket[] -} - -export interface TriageProposal { - id: string - workspaceId: string - title: string - summary: string - reports: Report[] - suggestion: { kind: 'new' } | { kind: 'duplicate'; key: string; confidence: number } - at: string -} - -export interface Release { - id: string - workspaceId: string - name: string - date: string - notes: string - notified: number -} - -export interface Workspace { - id: string - name: string - description: string - tracker: { kind: TrackerKind; label: string; synced?: string } - feedbackSources: { source: FeedbackSource; label: string }[] - dashboards: string[] - needsYou: number -} - -export interface Chat { - id: string - title: string - workspaceId?: string - owner?: string - /** Relative time of the last message, as the sidebar shows it. */ - age: string -} +} from '@/app/playground/org/fixtures/infra-data' +import { SUPPORT_ISSUES } from '@/app/playground/org/fixtures/support-data' +import type { + Chat, + Issue, + IssueStatus, + LinkedTicket, + Person, + PlanStep, + Release, + Report, + ResourceItem, + TriageProposal, + Workspace, +} from '@/app/playground/org/lib/types' export const ORGANIZATION = { id: 'acme', name: 'Acme Inc' } @@ -121,10 +34,27 @@ export const PEOPLE = { } satisfies Record export const WORKSPACES: Workspace[] = [ + { + id: 'claims', + name: 'Payment variance agent', + description: + 'Investigates payment variance and provider-service backlog, drafts briefs, and routes cases for analyst review', + match: /payment|claim|variance|provider|backlog|billing/i, + matchWorkflows: [ + 'Payment Exception Investigation', + 'Provider Service Recovery', + 'Analyst Review - Demo Cases', + ], + tracker: { kind: 'jira', label: 'Jira', synced: '3m ago' }, + feedbackSources: [{ source: 'slack', label: '#claims-analysts' }], + dashboards: [], + needsYou: 2, + }, { id: 'infra', name: 'Infra analyzer', description: 'Investigates workflow issues from feedback and stops for approval', + match: /infra|incident|analy|ops|sre|platform/i, tracker: { kind: 'linear', label: 'Linear', synced: '1m ago' }, feedbackSources: [{ source: 'slack', label: '#bot-feedback' }], dashboards: ['infra-analyzer'], @@ -134,6 +64,7 @@ export const WORKSPACES: Workspace[] = [ id: 'support', name: 'Support desk', description: 'Answers, routes, and learns from customer tickets', + match: /support|desk|help|ticket|service/i, tracker: { kind: 'linear', label: 'Linear', synced: '2m ago' }, feedbackSources: [ { source: 'slack', label: '#product-feedback' }, @@ -147,6 +78,7 @@ export const WORKSPACES: Workspace[] = [ id: 'growth', name: 'Growth engine', description: 'Finds, enriches, and activates new leads', + match: /growth|lead|sales|market|outbound/i, tracker: { kind: 'jira', label: 'Jira', synced: '4m ago' }, feedbackSources: [{ source: 'slack', label: '#growth-ideas' }], dashboards: ['growth-funnel'], @@ -156,6 +88,7 @@ export const WORKSPACES: Workspace[] = [ id: 'finance', name: 'Finance close', description: 'Reconciles spend and closes the books each month', + match: /finance|close|spend|ledger|expense/i, tracker: { kind: 'sim', label: 'Sim tracker' }, feedbackSources: [], dashboards: [], @@ -164,6 +97,7 @@ export const WORKSPACES: Workspace[] = [ ] export const CHATS: Chat[] = [ + ...CLAIMS_CHATS, { id: 'c0', age: '5m', @@ -295,7 +229,7 @@ const OTHER_RELEASES: Release[] = [ }, ] -export const RELEASES: Release[] = [...INFRA_RELEASES, ...OTHER_RELEASES] +export const RELEASES: Release[] = [...CLAIMS_RELEASES, ...INFRA_RELEASES, ...OTHER_RELEASES] const slack = (author: string, text: string, at: string): Report => ({ source: 'slack', @@ -364,7 +298,7 @@ const OTHER_TRIAGE: TriageProposal[] = [ }, ] -export const TRIAGE: TriageProposal[] = [...INFRA_TRIAGE, ...OTHER_TRIAGE] +export const TRIAGE: TriageProposal[] = [...CLAIMS_TRIAGE, ...INFRA_TRIAGE, ...OTHER_TRIAGE] const done = (label: string): PlanStep => ({ label, status: 'completed' }) const doing = (label: string): PlanStep => ({ label, status: 'inProgress' }) @@ -687,42 +621,13 @@ const OTHER_ISSUES: Issue[] = [ }, ] -export const ISSUES: Issue[] = [...INFRA_ISSUES, ...SUPPORT_ISSUES, ...OTHER_ISSUES] - -export const STATUS_ORDER: IssueStatus[] = [ - 'in_progress', - 'blocked', - 'todo', - 'backlog', - 'done', - 'canceled', +export const ISSUES: Issue[] = [ + ...CLAIMS_ISSUES, + ...INFRA_ISSUES, + ...SUPPORT_ISSUES, + ...OTHER_ISSUES, ] -export const STATUS_LABELS: Record = { - backlog: 'Backlog', - todo: 'Todo', - in_progress: 'In progress', - blocked: 'Blocked', - done: 'Done', - canceled: 'Canceled', -} - -export const PRIORITY_LABELS: Record = { - 0: 'No priority', - 1: 'Urgent', - 2: 'High', - 3: 'Medium', - 4: 'Low', -} - -export interface ResourceItem { - id: string - name: string - meta: string - updated: string - owner: string -} - export const RESOURCES: Record< 'workflows' | 'logs' | 'tables' | 'files' | 'knowledge' | 'credentials', ResourceItem[] @@ -867,12 +772,13 @@ export function workspaceById(id: string): Workspace { return workspace } -export function issueByKey(key: string): Issue | undefined { - return ISSUES.find((issue) => issue.key === key) +const TRACKER_PREFIX: Record = { + infra: 'BOT', + support: 'SUPT', + growth: 'GRW', + claims: 'CLM', } -const TRACKER_PREFIX: Record = { infra: 'BOT', support: 'SUPT', growth: 'GRW' } - const TRACKER_STATUS: Record = { backlog: 'Backlog', todo: 'Todo', diff --git a/apps/sim/app/playground/org/fixtures/overlays.ts b/apps/sim/app/playground/org/fixtures/overlays.ts new file mode 100644 index 00000000000..c0f2ea84210 --- /dev/null +++ b/apps/sim/app/playground/org/fixtures/overlays.ts @@ -0,0 +1,32 @@ +import { WORKSPACES } from '@/app/playground/org/fixtures/mock-data' +import type { Project } from '@/app/playground/org/lib/project' +import type { Workspace } from '@/app/playground/org/lib/types' + +/** What a real workspace shows when no pack claims it: real resources, nothing mock on top. */ +export const NO_PACK: Workspace = { + id: 'none', + name: '', + description: '', + tracker: { kind: 'sim', label: 'Sim tracker' }, + feedbackSources: [], + dashboards: [], + needsYou: 0, +} + +/** + * Picks the overlay pack for a real workspace: first the pack whose `matchWorkflows` names one + * of the workspace's workflows, then the pack whose `match` hits the workspace name. A workspace + * nothing claims shows only its real resources. + */ +export function matchOverlay( + name: string, + workflowNames: readonly string[] +): Pick { + const byWorkflow = WORKSPACES.find((pack) => + pack.matchWorkflows?.some((wanted) => workflowNames.includes(wanted)) + ) + const matched = byWorkflow ?? WORKSPACES.find((pack) => pack.match?.test(name)) + return matched + ? { mock: matched, overlayMatched: true } + : { mock: NO_PACK, overlayMatched: false } +} diff --git a/apps/sim/app/playground/org/fixtures/project-sources.ts b/apps/sim/app/playground/org/fixtures/project-sources.ts new file mode 100644 index 00000000000..64f6195c5f7 --- /dev/null +++ b/apps/sim/app/playground/org/fixtures/project-sources.ts @@ -0,0 +1,54 @@ +import { CHANGELOG, DRAFTS } from '@/app/playground/org/fixtures/changelog-data' +import { + CHATS, + ISSUES, + linkedTickets, + RELEASES, + ticketUrl, +} from '@/app/playground/org/fixtures/mock-data' +import type { Project } from '@/app/playground/org/lib/project' +import type { IssueChat, ProjectSources } from '@/app/playground/org/lib/project-sources' + +const RUNNING_CHAT_IDS: ReadonlySet = new Set( + DRAFTS.flatMap((draft) => draft.running.map((work) => work.chat.id)) +) + +/** Chats that produced or are running work on an issue, running ones first. */ +function issueChats(project: Project, issueKey: string): IssueChat[] { + const found = new Map() + for (const draft of DRAFTS) { + if (draft.workspaceId !== project.mock.id) continue + for (const work of draft.running) + if (work.issue === issueKey) found.set(work.chat.id, { ...work.chat, running: true }) + for (const change of draft.changes) + if (change.chat && change.issues.includes(issueKey) && !found.has(change.chat.id)) + found.set(change.chat.id, { ...change.chat, running: false }) + } + for (const entry of CHANGELOG) { + if (entry.workspaceId !== project.mock.id) continue + for (const change of entry.changes) + if (change.chat && change.issues.includes(issueKey) && !found.has(change.chat.id)) + found.set(change.chat.id, { ...change.chat, running: false }) + } + return [...found.values()] +} + +/** The overlay packs as project sources: every entity comes from the pack the project matched. */ +export const fixtureProjectSources: ProjectSources = { + issuesFor: (project) => ISSUES.filter((issue) => issue.workspaceId === project.mock.id), + changelogFor: (project) => ({ + shipped: CHANGELOG.filter((entry) => entry.workspaceId === project.mock.id), + draft: DRAFTS.find((draft) => draft.workspaceId === project.mock.id) ?? null, + }), + descriptionFor: (project) => (project.overlayMatched ? project.mock.description : ''), + needsYouFor: (project) => project.mock.needsYou, + chatsFor: (project) => + CHATS.filter((chat) => chat.workspaceId === project.mock.id).map((chat) => ({ + ...chat, + running: RUNNING_CHAT_IDS.has(chat.id), + })), + issueChatsFor: issueChats, + releaseFor: (_project, issue) => RELEASES.find((release) => release.id === issue.releaseId), + linkedTicketsFor: (_project, issue) => linkedTickets(issue), + ticketUrl, +} diff --git a/apps/sim/app/playground/org/lib/support-data.ts b/apps/sim/app/playground/org/fixtures/support-data.ts similarity index 98% rename from apps/sim/app/playground/org/lib/support-data.ts rename to apps/sim/app/playground/org/fixtures/support-data.ts index d72e0cb09b4..81982517b2c 100644 --- a/apps/sim/app/playground/org/lib/support-data.ts +++ b/apps/sim/app/playground/org/fixtures/support-data.ts @@ -1,5 +1,4 @@ -import type { Investigation } from '@/app/playground/org/lib/infra-data' -import type { Issue } from '@/app/playground/org/lib/mock-data' +import type { Investigation, Issue } from '@/app/playground/org/lib/types' const AVA = { id: 'ava', name: 'Ava Chen' } diff --git a/apps/sim/app/playground/org/layout.tsx b/apps/sim/app/playground/org/layout.tsx index ba62e526214..26bbc82b36d 100644 --- a/apps/sim/app/playground/org/layout.tsx +++ b/apps/sim/app/playground/org/layout.tsx @@ -1,10 +1,28 @@ import type { ReactNode } from 'react' import { notFound } from 'next/navigation' import { env, isTruthy } from '@/lib/core/config/env' +import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' import { ProtoShell } from '@/app/playground/org/components/proto-shell' +import { FeatureFlagsProvider } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' -/** Mock-data prototype of the org-level workspace view. Gated like the EMCN playground. */ -export default function OrgPrototypeLayout({ children }: { children: ReactNode }) { +/** Org-level project view over real workspace data. Gated like the EMCN playground. */ +export default async function OrgPrototypeLayout({ children }: { children: ReactNode }) { if (!isTruthy(env.NEXT_PUBLIC_ENABLE_PLAYGROUND)) notFound() - return {children} + const [modelSelectorEnabled, planModeEnabled] = await Promise.all([ + isMothershipModelSelectorEnabled(), + isPlanModeEnabled(), + ]) + return ( + + {children} + + ) } diff --git a/apps/sim/app/playground/org/lib/issue-vocabulary.ts b/apps/sim/app/playground/org/lib/issue-vocabulary.ts new file mode 100644 index 00000000000..895c8af5c7e --- /dev/null +++ b/apps/sim/app/playground/org/lib/issue-vocabulary.ts @@ -0,0 +1,28 @@ +import type { IssueStatus, Priority } from '@/app/playground/org/lib/types' + +/** Status groups in the order the issues list reads them. */ +export const STATUS_ORDER: IssueStatus[] = [ + 'in_progress', + 'blocked', + 'todo', + 'backlog', + 'done', + 'canceled', +] + +export const STATUS_LABELS: Record = { + backlog: 'Backlog', + todo: 'Todo', + in_progress: 'In progress', + blocked: 'Blocked', + done: 'Done', + canceled: 'Canceled', +} + +export const PRIORITY_LABELS: Record = { + 0: 'No priority', + 1: 'Urgent', + 2: 'High', + 3: 'Medium', + 4: 'Low', +} diff --git a/apps/sim/app/playground/org/lib/project-sources.tsx b/apps/sim/app/playground/org/lib/project-sources.tsx new file mode 100644 index 00000000000..5c77d41dcd9 --- /dev/null +++ b/apps/sim/app/playground/org/lib/project-sources.tsx @@ -0,0 +1,79 @@ +'use client' + +import { createContext, type ReactNode, useContext } from 'react' +import type { Project } from '@/app/playground/org/lib/project' +import type { + ChangeChat, + ChangelogEntry, + Chat, + DraftRelease, + Issue, + LinkedTicket, + Release, +} from '@/app/playground/org/lib/types' + +/** A chat of the project, with whether Sim is still working in it. */ +export interface ProjectChat extends Chat { + running: boolean +} + +/** A chat that produced or is running work on an issue. */ +export interface IssueChat extends ChangeChat { + running: boolean +} + +/** One environment's changelog: what shipped, and the next release being drafted with its running work. */ +export interface ProjectChangelog { + shipped: ChangelogEntry[] + draft: DraftRelease | null +} + +/** + * Where the project view reads the entities that have no real source yet. The playground mounts + * the fixture packs; the graduated surface ships with `emptyProjectSources` until each entity + * gets a real source. + */ +export interface ProjectSources { + /** Issues are shared by every environment of a project. */ + issuesFor(project: Project): Issue[] + changelogFor(project: Project): ProjectChangelog + /** The project's purpose line; empty when the source has none, so the caller falls back. */ + descriptionFor(project: Project): string + /** How many issues wait on a person; the sidebar badge. */ + needsYouFor(project: Project): number + chatsFor(project: Project): ProjectChat[] + issueChatsFor(project: Project, issueKey: string): IssueChat[] + releaseFor(project: Project, issue: Issue): Release | undefined + linkedTicketsFor(project: Project, issue: Issue): LinkedTicket[] + ticketUrl(ticket: LinkedTicket): string +} + +/** No source behind any entity: empty lists, no badge, and the workspace description fallback. */ +export const emptyProjectSources: ProjectSources = { + issuesFor: () => [], + changelogFor: () => ({ shipped: [], draft: null }), + descriptionFor: () => '', + needsYouFor: () => 0, + chatsFor: () => [], + issueChatsFor: () => [], + releaseFor: () => undefined, + linkedTicketsFor: (_project, issue) => issue.linked ?? [], + ticketUrl: () => '', +} + +const ProjectSourcesContext = createContext(null) + +interface ProjectSourcesProviderProps { + sources: ProjectSources + children: ReactNode +} + +export function ProjectSourcesProvider({ sources, children }: ProjectSourcesProviderProps) { + return {children} +} + +export function useProjectSources(): ProjectSources { + const sources = useContext(ProjectSourcesContext) + if (!sources) throw new Error('useProjectSources must be used within ProjectSourcesProvider') + return sources +} diff --git a/apps/sim/app/playground/org/lib/project.ts b/apps/sim/app/playground/org/lib/project.ts new file mode 100644 index 00000000000..b3f7eb8062c --- /dev/null +++ b/apps/sim/app/playground/org/lib/project.ts @@ -0,0 +1,119 @@ +import { useQueries } from '@tanstack/react-query' +import { DEPTH_LABELS, type ProjectEnvironment, resolveProjectLineages } from '@/lib/projects' +import { matchOverlay, WORKSPACES } from '@/app/playground/org/fixtures' +import type { Workspace } from '@/app/playground/org/lib/types' +import { getWorkflowListQueryOptions } from '@/hooks/queries/utils/workflow-list-query' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' + +/** A real workspace shown as a project, plus the mock overlay for the entities that do not exist yet. */ +export interface Project { + /** Real workspace id; every route and hook uses it. */ + id: string + name: string + organizationId: string | null + /** Issues, changelog, tracker and feedback sources have no real source, so they come from a pack. */ + mock: Workspace + /** True when the pack was chosen by what the workspace holds or is called, not by position. */ + overlayMatched: boolean + /** True until the workspace's workflow list has loaded, so the pack may still change. */ + overlayPending: boolean + /** A pack shown as a project of its own because no real workspace claimed it; everything is mock. */ + isMock: boolean + /** The workspace at the top of this fork lineage; the sidebar lists one project per root. */ + rootId: string + /** Which environment this workspace is within its project: Prod, Staging, Sandbox. */ + environment: string + /** Every environment of the project, root first. */ + environments: ProjectEnvironment[] +} + +/** The ids the mock projects live at; a route with one of these never touches a real workspace. */ +export const MOCK_PROJECT_IDS: ReadonlySet = new Set(WORKSPACES.map((pack) => pack.id)) + +function mockProject(pack: Workspace): Project { + return { + id: pack.id, + name: pack.name, + organizationId: null, + mock: pack, + overlayMatched: true, + overlayPending: false, + isMock: true, + rootId: pack.id, + environment: DEPTH_LABELS[0], + environments: [{ workspaceId: pack.id, label: DEPTH_LABELS[0] }], + } +} + +export function useProjects() { + const query = useWorkspacesQuery() + const workspaces = query.data ?? [] + /** Shares the workflow list cache with every page that renders the same workspace. */ + const workflowLists = useQueries({ + queries: workspaces.map((workspace) => getWorkflowListQueryOptions(workspace.id)), + }) + /** A handful of rows, so deriving them each render is cheaper than a memo keyed on the lists. */ + const lineages = resolveProjectLineages(workspaces) + const indexById = new Map(workspaces.map((workspace, index) => [workspace.id, index])) + /** + * Issues, changelog and the rest of the pack belong to the project, so the pack is chosen + * once per lineage from its root workspace and shared by every environment. + */ + const overlayByRoot = new Map< + string, + Pick + >() + const overlayFor = (rootId: string) => { + const known = overlayByRoot.get(rootId) + if (known) return known + const rootIndex = indexById.get(rootId) + if (rootIndex === undefined) throw new Error(`No root workspace ${rootId}`) + const overlay = { + ...matchOverlay( + workspaces[rootIndex].name, + (workflowLists[rootIndex]?.data ?? []).map((workflow) => workflow.name) + ), + overlayPending: workflowLists[rootIndex]?.isPending ?? true, + } + overlayByRoot.set(rootId, overlay) + return overlay + } + const real: Project[] = workspaces.map((workspace) => { + const lineage = lineages.get(workspace.id) + if (!lineage) throw new Error(`No lineage for workspace ${workspace.id}`) + return { + id: workspace.id, + name: lineage.name, + organizationId: workspace.organizationId ?? null, + ...overlayFor(lineage.rootId), + isMock: false, + rootId: lineage.rootId, + environment: lineage.environment, + environments: lineage.environments, + } + }) + /** Packs no real workspace claimed stay in the sidebar as mock projects, below the real ones. */ + const claimed = new Set(real.map((project) => project.mock.id)) + const settled = !query.isPending && real.every((project) => !project.overlayPending) + const mocks = settled ? WORKSPACES.filter((pack) => !claimed.has(pack.id)).map(mockProject) : [] + const projects = [...real, ...mocks] + /** One entry per project for the sidebar and pickers: each lineage's root, then the mocks. */ + const roots = projects.filter((project) => project.rootId === project.id) + return { projects, roots, isPending: query.isPending, error: query.error } +} + +export function useProject(workspaceId: string) { + const { projects, isPending, error } = useProjects() + const found = projects.find((project) => project.id === workspaceId) + /** A mock id resolves at once so its pages never wait on the real lists. */ + const pack = + !found && MOCK_PROJECT_IDS.has(workspaceId) + ? WORKSPACES.find((candidate) => candidate.id === workspaceId) + : undefined + return { project: found ?? (pack ? mockProject(pack) : undefined), isPending, error } +} + +/** The workspace id a hook may query for this project; empty for a mock project so nothing fires. */ +export function realWorkspaceId(project: Project): string { + return project.isMock ? '' : project.id +} diff --git a/apps/sim/app/playground/org/lib/routes.ts b/apps/sim/app/playground/org/lib/routes.ts index 9ae4e2c5cb8..91be37f633b 100644 --- a/apps/sim/app/playground/org/lib/routes.ts +++ b/apps/sim/app/playground/org/lib/routes.ts @@ -6,11 +6,16 @@ import { Library, ListChecks, Rss, + Server, Settings, Table, Workflow, } from '@sim/emcn/icons' -import { CHATS } from '@/app/playground/org/lib/mock-data' +import { + getWorkspaceSettingsHref, + type WorkspaceSettingsSection, +} from '@/components/settings/navigation' +import { MOCK_PROJECT_IDS, type Project } from '@/app/playground/org/lib/project' import { DEFAULT_SETTINGS_SECTION, SETTINGS_NAV } from '@/app/playground/org/lib/settings-nav' export const PROTO_BASE = '/playground/org' @@ -19,6 +24,7 @@ export const WORKSPACE_SECTIONS = [ { id: 'dashboard', label: 'Dashboard', icon: Layout }, { id: 'changelog', label: 'Changelog', icon: Rss }, { id: 'issues', label: 'Issues', icon: ListChecks }, + { id: 'environments', label: 'Environments', icon: Server }, { id: 'credentials', label: 'Integrations', icon: Integration }, { id: 'workflows', label: 'Workflows', icon: Workflow }, { id: 'files', label: 'Files', icon: Files }, @@ -30,12 +36,22 @@ export const WORKSPACE_SECTIONS = [ export type WorkspaceSection = (typeof WORKSPACE_SECTIONS)[number]['id'] -/** What the main project view shows as chips; everything else lives in the full view. */ -export const MAIN_SECTION_IDS: readonly WorkspaceSection[] = ['dashboard', 'changelog', 'issues'] +const MAIN_SECTION_LIST = ['dashboard', 'changelog', 'issues', 'environments'] as const + +/** A section of the project main view. */ +export type MainSection = (typeof MAIN_SECTION_LIST)[number] +/** A Build section: a real workspace page, or a mock project's static table. */ +export type BuildSection = Exclude + +/** What the main project view shows as chips; everything else lives in Build. */ +export const MAIN_SECTION_IDS: readonly WorkspaceSection[] = MAIN_SECTION_LIST export const MAIN_SECTIONS = WORKSPACE_SECTIONS.filter((s) => MAIN_SECTION_IDS.includes(s.id)) +export const BUILD_SECTION_IDS: readonly BuildSection[] = WORKSPACE_SECTIONS.map( + (s) => s.id +).filter((id): id is BuildSection => !isMainSection(id)) -function isMainSection(section: WorkspaceSection): boolean { - return MAIN_SECTION_IDS.includes(section) +export function isMainSection(section: WorkspaceSection): section is MainSection { + return (MAIN_SECTION_LIST as readonly WorkspaceSection[]).includes(section) } export const protoRoutes = { @@ -43,22 +59,22 @@ export const protoRoutes = { search: `${PROTO_BASE}/search`, connectors: `${PROTO_BASE}/connectors`, /** Project chats slide in beside their project; org-level chats open on their own page. */ - chat: (chatId: string) => { - const chat = CHATS.find((c) => c.id === chatId) - if (!chat) throw new Error(`Unknown chat ${chatId}`) - return chat.workspaceId - ? `${PROTO_BASE}/p/${chat.workspaceId}/dashboard?chat=${chatId}` - : `${PROTO_BASE}/chat/${chatId}` - }, + chat: (workspaceId: string | null, chatId: string) => + workspaceId + ? `${PROTO_BASE}/p/${workspaceId}/dashboard?chat=${chatId}` + : `${PROTO_BASE}/chat/${chatId}`, /** Main project view for dashboard/changelog/issues; any other section opens the full view. */ workspace: (workspaceId: string, section: WorkspaceSection = 'dashboard') => isMainSection(section) ? `${PROTO_BASE}/p/${workspaceId}/${section}` : `${PROTO_BASE}/p/${workspaceId}/build/${section}`, - /** Settings replace the project sidebar with the settings list. */ + /** Mock projects only: settings replace the project sidebar with the settings list. */ settings: (workspaceId: string, section: string = DEFAULT_SETTINGS_SECTION) => `${PROTO_BASE}/p/${workspaceId}/build/settings/${section}`, - /** Full view: the sidebar becomes this project's navigation. */ + /** + * Mock projects only: the playground's static Build tables, with the sidebar as the + * project's navigation. A real project's Build sections are its workspace pages. + */ full: (workspaceId: string, section: WorkspaceSection = 'dashboard') => `${PROTO_BASE}/p/${workspaceId}/build/${section}`, issue: (workspaceId: string, key: string) => `${PROTO_BASE}/p/${workspaceId}/issue/${key}`, @@ -86,12 +102,20 @@ export function parseProtoRoute(slug: string[] | undefined): ProtoRoute | null { if (head === 'chat' && a && !b) return { kind: 'chat', chatId: a } if (head === 'p' && a) { if (b === 'issue' && c) return { kind: 'issue', workspaceId: a, issueKey: c } - if (b === 'build' && c === 'settings') { - const settingsSection = d ?? DEFAULT_SETTINGS_SECTION - if (!SETTINGS_NAV.some((item) => item.id === settingsSection)) return null - return { kind: 'workspace', workspaceId: a, section: 'settings', full: true, settingsSection } - } if (b === 'build') { + /** Only a mock project builds here; a real project's Build sections are its workspace pages. */ + if (!MOCK_PROJECT_IDS.has(a)) return null + if (c === 'settings') { + const settingsSection = d ?? DEFAULT_SETTINGS_SECTION + if (!SETTINGS_NAV.some((item) => item.id === settingsSection)) return null + return { + kind: 'workspace', + workspaceId: a, + section: 'settings', + full: true, + settingsSection, + } + } const section = WORKSPACE_SECTIONS.find((s) => s.id === (c ?? 'dashboard')) if (section) return { kind: 'workspace', workspaceId: a, section: section.id, full: true } return null @@ -103,14 +127,59 @@ export function parseProtoRoute(slug: string[] | undefined): ProtoRoute | null { return null } -/** The project whose full view is open, read from the path; null in the org view. */ +/** The mock project whose full view is open, read from the path; null in the org view. */ export function fullViewProject(pathname: string | null): string | null { const match = pathname?.match(/\/p\/([^/]+)\/build(?:\/|$)/) return match ? match[1] : null } -/** The project whose settings are open, read from the path; null elsewhere. */ +/** The mock project whose settings are open, read from the path; null elsewhere. */ export function settingsProject(pathname: string | null): string | null { const match = pathname?.match(/\/p\/([^/]+)\/build\/settings(?:\/|$)/) return match ? match[1] : null } + +/** The real workspace pages a project's resources open in. */ +export const workspaceRoutes = { + workflows: (workspaceId: string) => `/workspace/${workspaceId}/w`, + workflow: (workspaceId: string, workflowId: string) => + `/workspace/${workspaceId}/w/${workflowId}`, + files: (workspaceId: string) => `/workspace/${workspaceId}/files`, + file: (workspaceId: string, fileId: string) => `/workspace/${workspaceId}/files/${fileId}`, + tables: (workspaceId: string) => `/workspace/${workspaceId}/tables`, + table: (workspaceId: string, tableId: string) => `/workspace/${workspaceId}/tables/${tableId}`, + knowledge: (workspaceId: string) => `/workspace/${workspaceId}/knowledge`, + knowledgeBase: (workspaceId: string, id: string) => `/workspace/${workspaceId}/knowledge/${id}`, + logs: (workspaceId: string, executionId?: string | null) => + executionId + ? `/workspace/${workspaceId}/logs?executionId=${encodeURIComponent(executionId)}` + : `/workspace/${workspaceId}/logs`, + integrations: (workspaceId: string) => `/workspace/${workspaceId}/integrations`, + credential: (workspaceId: string, credentialId: string) => + `/workspace/${workspaceId}/integrations/connected/${credentialId}`, + settings: (workspaceId: string, section: WorkspaceSettingsSection) => + getWorkspaceSettingsHref(workspaceId, section), + /** The settings page every workspace has; `/settings` itself redirects here. */ + settingsHome: (workspaceId: string) => `/workspace/${workspaceId}/settings/general`, +} + +const BUILD_PAGES: Record string> = { + credentials: workspaceRoutes.integrations, + workflows: workspaceRoutes.workflows, + files: workspaceRoutes.files, + logs: (workspaceId) => workspaceRoutes.logs(workspaceId), + tables: workspaceRoutes.tables, + knowledge: workspaceRoutes.knowledge, + settings: workspaceRoutes.settingsHome, +} + +/** + * Where a project's Build section opens: the real workspace page, or, for a mock project with + * no workspace behind it, the playground's static table. + */ +export function buildSectionHref( + project: Pick, + section: BuildSection +): string { + return project.isMock ? protoRoutes.full(project.id, section) : BUILD_PAGES[section](project.id) +} diff --git a/apps/sim/app/playground/org/lib/search-params.ts b/apps/sim/app/playground/org/lib/search-params.ts index 388439871e3..e1e2d27696e 100644 --- a/apps/sim/app/playground/org/lib/search-params.ts +++ b/apps/sim/app/playground/org/lib/search-params.ts @@ -1,13 +1,15 @@ import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { RESOURCE_TAB_IDS } from '@/app/playground/org/components/environments/mapping-model' export const protoParsers = { - /** Chat open in the slide-in panel next to the project; empty when closed. */ + /** Chat open in the slide-in panel next to the project; `new` before the first message. */ chat: parseAsString.withDefault(''), - /** Project a new chat starts in; `none` means an org-wide chat. */ - project: parseAsString.withDefault('infra'), - dashboard: parseAsStringLiteral([ - 'support-operations', - 'infra-analyzer', - 'growth-funnel', - ] as const), + /** Project a new chat starts in; empty means an org-wide chat. */ + project: parseAsString.withDefault(''), + /** Dashboard shown on the project page: a dashboard file id, `runs`, or a sample id. */ + dashboard: parseAsString.withDefault(''), + /** First message handed from the home composer to a project's new chat; sent once, then cleared. */ + q: parseAsString.withDefault(''), + /** Resource type shown in the project's Environments tab: workflows, then the mapping kinds. */ + resource: parseAsStringLiteral(RESOURCE_TAB_IDS).withDefault('workflow'), } diff --git a/apps/sim/app/playground/org/lib/types.ts b/apps/sim/app/playground/org/lib/types.ts new file mode 100644 index 00000000000..b5fe450d418 --- /dev/null +++ b/apps/sim/app/playground/org/lib/types.ts @@ -0,0 +1,303 @@ +/** + * The entities the project view shows that have no real source yet: issues, changelog, + * chats and the overlay packs that hold them. Fixtures implement them; nothing here pulls data. + */ + +export type IssueStatus = 'backlog' | 'todo' | 'in_progress' | 'blocked' | 'done' | 'canceled' +export type Priority = 0 | 1 | 2 | 3 | 4 +export type AgentState = 'pending' | 'active' | 'awaitingInput' | 'error' | 'complete' | 'stale' +export type FeedbackSource = 'slack' | 'email' | 'intercom' | 'pagerduty' +export type TrackerKind = 'sim' | 'linear' | 'jira' + +export interface Person { + id: string + name: string +} + +export interface Delegate { + kind: 'agent' | 'workflow' | 'sim' + name: string +} + +export interface Report { + source: FeedbackSource + channel: string + author: string + text: string + at: string +} + +export interface PlanStep { + label: string + status: 'pending' | 'inProgress' | 'completed' | 'canceled' +} + +export interface Activity { + kind: 'thought' | 'action' | 'elicitation' | 'response' | 'error' + text: string + at: string +} + +/** A ticket in Linear, Jira, or GitHub that a Sim issue points at. */ +export interface LinkedTicket { + system: 'linear' | 'jira' | 'github' + key: string + title: string + status: string +} + +export interface Issue { + key: string + workspaceId: string + title: string + status: IssueStatus + priority: Priority + owner: Person + delegate?: Delegate + agent?: { state: AgentState; label: string } + project?: string + labels: string[] + due?: string + description: string + research?: { cause: string; related: string[] } + reports: Report[] + plan: PlanStep[] + activity: Activity[] + releaseId?: string + resources: { kind: 'table' | 'workflow' | 'dashboard' | 'knowledge' | 'file'; name: string }[] + investigation?: Investigation + /** Tickets in external trackers this Sim issue covers; derived from the project tracker when omitted. */ + linked?: LinkedTicket[] +} + +export interface TriageProposal { + id: string + workspaceId: string + title: string + summary: string + reports: Report[] + suggestion: { kind: 'new' } | { kind: 'duplicate'; key: string; confidence: number } + at: string +} + +export interface Release { + id: string + workspaceId: string + name: string + date: string + notes: string + notified: number +} + +/** + * An overlay pack: the issues, changelog, tracker and feedback sources a real workspace shows + * while those entities have no real source. `match` picks the pack from the workspace name. + */ +export interface Workspace { + id: string + name: string + description: string + /** Picks this pack when the workspace name matches; a name-independent signal is `matchWorkflows`. */ + match?: RegExp + /** Picks this pack when the workspace holds a workflow with one of these exact names. */ + matchWorkflows?: string[] + tracker: { kind: TrackerKind; label: string; synced?: string } + feedbackSources: { source: FeedbackSource; label: string }[] + dashboards: string[] + needsYou: number +} + +export interface Chat { + id: string + title: string + workspaceId?: string + owner?: string + /** Relative time of the last message, as the sidebar shows it. */ + age: string +} + +export interface ResourceItem { + id: string + name: string + meta: string + updated: string + owner: string +} + +export interface RunBlock { + id: string + name: string + type: string + /** Offset from the run start, in ms. */ + start: number + duration: number + depth: number + output?: string + highlight?: boolean +} + +export type Evidence = + | { type: 'run'; runId: string; summary: string; blocks: RunBlock[] } + | { type: 'runs'; title: string; columns: string[]; rows: string[][]; flagged: number[] } + | { + type: 'diff' + title: string + subtitle: string + lines: { kind: 'add' | 'del' | 'ctx'; text: string }[] + } + +export interface InvestigationStep { + at: string + kind: 'feedback' | 'check' | 'workflow' | 'runs' | 'run' | 'config' | 'history' | 'conclusion' + title: string + detail?: string + evidence?: Evidence +} + +export interface SourceMessage { + author: string + channel: string + at: string + text: string +} + +export type Origin = + | { kind: 'slack'; message: SourceMessage; thread: SourceMessage[] } + | { + kind: 'kbQuery' + knowledgeBase: string + query: string + at: string + detail: string + answers: { answer: string; cites: string }[] + } + +export interface SourceDoc { + system: 'confluence' + title: string + space: string + author: string + updated: string + /** Paragraphs from the page; `highlight` marks the sentence that conflicts. */ + excerpt: string[] + highlight: string +} + +export interface InvestigationSource { + kind: 'slack' | 'workflow' | 'logs' | 'history' + label: string +} + +export interface Investigation { + workflow: { name: string; version: string; nextVersion: string } + summary: { label: string; text: string }[] + /** What started the investigation: a person in Slack, or Sim's own knowledge check. */ + origin: Origin + /** Source documents that disagree, when the issue is about knowledge content. */ + documents?: SourceDoc[] + sources: InvestigationSource[] + timeline: InvestigationStep[] + rootCause: { + summary: string + chain: string[] + confidence: 'High' | 'Medium' | 'Low' + confidenceReason: string + block: { path: string; lines: string[] } + } + frequency: { + query: string + days: { date: string; count: number }[] + stats: { label: string; value: string }[] + } + proposal: { + summary: string + diff: { kind: 'add' | 'del' | 'ctx'; text: string }[] + tradeoff: string + replay: string + /** A document edit handed to a Sim chat for approval, instead of a workflow redeploy. */ + docEdit?: { + system: 'confluence' + page: string + space: string + lines: { kind: 'add' | 'del' | 'ctx'; text: string }[] + } + } +} + +export interface ChangelogMetric { + label: string + before: number + after: number + unit?: string + /** Lower is better for this metric (drives the delta color). */ + lowerIsBetter?: boolean +} + +export interface ChangelogChart { + title: string + kind: 'bar' | 'line' + unit?: string + /** Daily values; the release day is `releaseIndex`. */ + values: number[] + releaseIndex: number +} + +/** A chat is where a person and their Sim made the change. */ +export interface ChangeChat { + id: string + owner: string +} + +export interface Change { + text: string + issues: string[] + chat?: ChangeChat + workflow?: string +} + +export interface ChangelogEntry { + id: string + workspaceId: string + date: string + version: string + workflows: string[] + title: string + summary: string + changes: Change[] + metrics: ChangelogMetric[] + chart?: ChangelogChart + reporters: string[] + channel: string + /** The newest shipped release streams a live "since release" counter. */ + live?: { label: string; goodLabel: string } +} + +export interface DraftChange extends Change { + workflow: string + /** `approval` changes are held until their issue is approved. */ + state: 'ready' | 'approval' +} + +export interface RunningWork { + issue: string + agent: string + task: string + step: string + /** What the agent says it's doing, newest last; the card cycles through these. */ + narration: string[] + progress: number + startedMinutesAgo: number + chat: ChangeChat +} + +export interface DraftRelease { + workspaceId: string + versions: { workflow: string; from: string; to: string }[] + changes: DraftChange[] + running: RunningWork[] + chart: ChangelogChart + /** Chart marker for the pending release. */ + marker: string + /** What the shipped entry says once Release is clicked. */ + release: { title: string; summary: string; reporters: string[]; channel: string } +} diff --git a/apps/sim/app/playground/org/lib/use-project-actions.ts b/apps/sim/app/playground/org/lib/use-project-actions.ts new file mode 100644 index 00000000000..cfff4c80b7d --- /dev/null +++ b/apps/sim/app/playground/org/lib/use-project-actions.ts @@ -0,0 +1,69 @@ +'use client' + +import { useCallback } from 'react' +import { parseEnvironmentName } from '@/lib/projects' +import type { Project } from '@/app/playground/org/lib/project' +import { + useDeleteWorkspace, + usePinnedWorkspaceIds, + useToggleWorkspacePin, + useUpdateWorkspace, + useWorkspacesQuery, +} from '@/hooks/queries/workspace' + +/** + * Rename, pin and delete for a project, applied to every workspace of its lineage: a rename + * keeps each environment's own suffix ("Staging", "Sandbox"), a delete removes forks before + * their parent, and the pin lives on the root. Mock projects have no workspace, so every + * action is a no-op for them. + */ +export function useProjectActions() { + const { data: workspaces } = useWorkspacesQuery() + const { data: pinnedIds } = usePinnedWorkspaceIds() + const { mutate: togglePin } = useToggleWorkspacePin() + const { mutateAsync: updateWorkspace, isPending: isRenaming } = useUpdateWorkspace() + const { mutateAsync: deleteWorkspace, isPending: isDeleting } = useDeleteWorkspace() + + const isPinned = useCallback( + (project: Project) => Boolean(pinnedIds?.has(project.rootId)), + [pinnedIds] + ) + + const setPinned = useCallback( + (project: Project, pinned: boolean) => { + if (project.isMock) return + togglePin({ workspaceId: project.rootId, pinned }) + }, + [togglePin] + ) + + const rename = useCallback( + async (project: Project, name: string) => { + const base = name.trim() + if (project.isMock || !base || base === project.name) return + for (const environment of project.environments) { + const workspace = workspaces?.find((candidate) => candidate.id === environment.workspaceId) + if (!workspace) continue + const { environment: suffix } = parseEnvironmentName(workspace.name) + await updateWorkspace({ + workspaceId: workspace.id, + name: suffix ? `${base} ${suffix}` : base, + }) + } + }, + [updateWorkspace, workspaces] + ) + + const remove = useCallback( + async (project: Project) => { + if (project.isMock) return + /** Forks first: the root is the last workspace to go, so a failure leaves a valid lineage. */ + for (const environment of [...project.environments].reverse()) { + await deleteWorkspace({ workspaceId: environment.workspaceId }) + } + }, + [deleteWorkspace] + ) + + return { isPinned, setPinned, rename, remove, isRenaming, isDeleting } +} diff --git a/apps/sim/app/playground/org/lib/use-project-order.ts b/apps/sim/app/playground/org/lib/use-project-order.ts new file mode 100644 index 00000000000..d7a9af9bc21 --- /dev/null +++ b/apps/sim/app/playground/org/lib/use-project-order.ts @@ -0,0 +1,86 @@ +'use client' + +import { useCallback, useState } from 'react' +import type { Project } from '@/app/playground/org/lib/project' +import { usePinnedWorkspaceIds } from '@/hooks/queries/workspace' +import { orderProjectIds, useProjectOrderStore } from '@/stores/project-order/store' + +/** Where a dragged project would land relative to the row under the pointer. */ +export interface ProjectDropIndicator { + projectId: string + position: 'before' | 'after' +} + +/** Drag handlers a project row binds; `indicator` is set while its row is the drop target. */ +export interface ProjectDragProps { + isDragging: boolean + indicator: 'before' | 'after' | null + onDragStart: () => void + onDragOver: (event: React.DragEvent) => void + onDragLeave: () => void + onDrop: (event: React.DragEvent) => void + onDragEnd: () => void +} + +/** + * The sidebar's project list in the viewer's order: pinned projects first, then the manual + * order, then everything the order does not know yet. Also owns the drag-to-reorder state + * the rows bind; a drop moves the dragged project before or after the row it landed on. + */ +export function useProjectOrder(roots: readonly Project[]) { + const { data: pinnedIds } = usePinnedWorkspaceIds() + const order = useProjectOrderStore((state) => state.order) + const moveProject = useProjectOrderStore((state) => state.moveProject) + const [draggingId, setDraggingId] = useState(null) + const [dropIndicator, setDropIndicator] = useState(null) + + const byId = new Map(roots.map((project) => [project.id, project])) + const ordered = orderProjectIds( + roots.map((project) => project.id), + order + ).map((id) => byId.get(id)!) + const pinned = ordered.filter((project) => pinnedIds?.has(project.rootId)) + const unpinned = ordered.filter((project) => !pinnedIds?.has(project.rootId)) + const projects = [...pinned, ...unpinned] + const visibleIds = projects.map((project) => project.id) + + const dragProps = useCallback( + (project: Project): ProjectDragProps => ({ + isDragging: draggingId === project.id, + indicator: dropIndicator?.projectId === project.id ? dropIndicator.position : null, + onDragStart: () => setDraggingId(project.id), + onDragOver: (event) => { + if (!draggingId || draggingId === project.id) return + event.preventDefault() + event.dataTransfer.dropEffect = 'move' + const rect = event.currentTarget.getBoundingClientRect() + const position = event.clientY < rect.top + rect.height / 2 ? 'before' : 'after' + setDropIndicator((current) => + current?.projectId === project.id && current.position === position + ? current + : { projectId: project.id, position } + ) + }, + onDragLeave: () => + setDropIndicator((current) => (current?.projectId === project.id ? null : current)), + onDrop: (event) => { + event.preventDefault() + if (!draggingId || draggingId === project.id) return + const rect = event.currentTarget.getBoundingClientRect() + const before = event.clientY < rect.top + rect.height / 2 + const index = visibleIds.indexOf(project.id) + const beforeId = before ? project.id : (visibleIds[index + 1] ?? null) + moveProject(visibleIds, draggingId, beforeId) + setDraggingId(null) + setDropIndicator(null) + }, + onDragEnd: () => { + setDraggingId(null) + setDropIndicator(null) + }, + }), + [draggingId, dropIndicator, moveProject, visibleIds] + ) + + return { projects, dragProps, isAnyDragActive: draggingId !== null } +} diff --git a/apps/sim/app/playground/org/lib/use-project-resources.ts b/apps/sim/app/playground/org/lib/use-project-resources.ts new file mode 100644 index 00000000000..b32c9c02010 --- /dev/null +++ b/apps/sim/app/playground/org/lib/use-project-resources.ts @@ -0,0 +1,75 @@ +import { useMemo } from 'react' +import { DASHBOARD_CONTENT_TYPE } from '@/lib/dashboards/file' +import { type Project, realWorkspaceId } from '@/app/playground/org/lib/project' +import { useProjectSources } from '@/app/playground/org/lib/project-sources' +import { useFolderMap } from '@/hooks/queries/folders' +import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' +import { useTablesList } from '@/hooks/queries/tables' +import { useWorkflows } from '@/hooks/queries/workflows' +import { useWorkspaceMembersQuery } from '@/hooks/queries/workspace' +import { useWorkspaceFiles } from '@/hooks/queries/workspace-files' + +/** Every resource list a project page reads, from the workspace's own React Query hooks. */ +export function useProjectResources(workspaceId: string) { + /** Empty for a mock project: every query stays off and the lists read as empty. */ + const enabled = Boolean(workspaceId) + const workflows = useWorkflows(workspaceId || undefined, { enabled }) + const folders = useFolderMap(workspaceId || undefined) + const tables = useTablesList(workspaceId || undefined, 'active', { enabled }) + const knowledgeBases = useKnowledgeBasesQuery(workspaceId || undefined, { + includeCounts: true, + enabled, + }) + const files = useWorkspaceFiles(workspaceId, 'active', { enabled }) + const members = useWorkspaceMembersQuery(workspaceId || undefined) + + const memberNames = useMemo(() => { + const names = new Map() + for (const member of members.data ?? []) names.set(member.userId, member.name) + return names + }, [members.data]) + + const fileLists = useMemo(() => { + const all = files.data ?? [] + return { + files: all, + dashboardFiles: all.filter((file) => file.type === DASHBOARD_CONTENT_TYPE), + } + }, [files.data]) + + return { + workflows: workflows.data ?? [], + folders: folders.data ?? {}, + tables: tables.data ?? [], + knowledgeBases: knowledgeBases.data ?? [], + files: fileLists.files, + dashboardFiles: fileLists.dashboardFiles, + memberNames, + isPending: + enabled && + (workflows.isPending || tables.isPending || knowledgeBases.isPending || files.isPending), + filesPending: enabled && files.isPending, + } +} + +export type ProjectResources = ReturnType + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}` +} + +/** The source's purpose line for this project when it has one; otherwise what the workspace holds. */ +export function useProjectDescription(project: Project): string { + const description = useProjectSources().descriptionFor(project) + const { workflows, tables, knowledgeBases, files, isPending } = useProjectResources( + realWorkspaceId(project) + ) + if (description) return description + if (isPending) return '' + return [ + plural(workflows.length, 'workflow'), + plural(tables.length, 'table'), + plural(knowledgeBases.length, 'knowledge base'), + plural(files.length, 'file'), + ].join(' · ') +} diff --git a/apps/sim/app/playground/org/p/[workspaceId]/[[...rest]]/page.tsx b/apps/sim/app/playground/org/p/[workspaceId]/[[...rest]]/page.tsx new file mode 100644 index 00000000000..39c5d457a26 --- /dev/null +++ b/apps/sim/app/playground/org/p/[workspaceId]/[[...rest]]/page.tsx @@ -0,0 +1,18 @@ +import { Suspense } from 'react' +import { ProtoPage } from '@/app/playground/org/components/proto-page' + +/** Project pages: the same client router as the catch-all, with the workspace as a real segment. */ +export default async function ProjectPage({ + params, +}: { + params: Promise<{ workspaceId: string; rest?: string[] }> +}) { + const { workspaceId, rest } = await params + return ( + Loading project…

    } + > + +
    + ) +} diff --git a/apps/sim/app/playground/org/p/[workspaceId]/layout.tsx b/apps/sim/app/playground/org/p/[workspaceId]/layout.tsx new file mode 100644 index 00000000000..affbba43016 --- /dev/null +++ b/apps/sim/app/playground/org/p/[workspaceId]/layout.tsx @@ -0,0 +1,23 @@ +import type { ReactNode } from 'react' +import { MOCK_PROJECT_IDS } from '@/app/playground/org/lib/project' +import { WorkspacePermissionsProvider } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' + +/** + * A real `[workspaceId]` segment so the Chat components that read `useParams().workspaceId` + * resolve the project's workspace, with the permissions the message actions expect. + */ +export default async function ProjectLayout({ + children, + params, +}: { + children: ReactNode + params: Promise<{ workspaceId: string }> +}) { + const { workspaceId } = await params + if (MOCK_PROJECT_IDS.has(workspaceId)) return children + return ( + + {children} + + ) +} diff --git a/apps/sim/app/playground/org/search/page.tsx b/apps/sim/app/playground/org/search/page.tsx new file mode 100644 index 00000000000..778e08217f4 --- /dev/null +++ b/apps/sim/app/playground/org/search/page.tsx @@ -0,0 +1,47 @@ +import { Suspense } from 'react' +import { redirect } from 'next/navigation' +import { getSession } from '@/lib/auth' +import { getActiveOrganizationId } from '@/lib/auth/session-response' +import { + getOrganizationSurfaceContext, + resolveOrganizationLanding, +} from '@/lib/organizations/surface' +import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' +import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider' +import { OrganizationSearch } from '@/app/o/[organizationId]/search/search' +import { protoRoutes } from '@/app/playground/org/lib/routes' + +/** Renders the authorized organization search surface inside the playground rail. */ +export default async function SearchPage() { + const session = await getSession() + if (!session?.user) + redirect(buildAuthCrossLink('/login', { callbackUrl: protoRoutes.search, isInviteFlow: false })) + /** The session's active organization, else the first the viewer belongs to, as the app entry does. */ + const organizationId = await resolveOrganizationLanding( + session.user.id, + getActiveOrganizationId(session) + ) + const context = organizationId + ? await getOrganizationSurfaceContext(organizationId, session.user.id) + : null + if (!context?.searchAccess.memberScoped) + return ( +
    +

    Search

    +

    + {organizationId + ? 'Sim Search is not enabled for this organization.' + : 'You are not a member of an organization.'} +

    +
    + ) + return ( + + Loading search…

    } + > + +
    +
    + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/constants.ts b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/constants.ts new file mode 100644 index 00000000000..0bb14f323f2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/constants.ts @@ -0,0 +1,87 @@ +import type { ComponentType } from 'react' +import { + Database, + Files, + Integration, + Layout, + Library, + ListChecks, + Rss, + Server, + Settings, + Table, + Workflow, +} from '@sim/emcn/icons' + +/** + * Where a project's main view lives. The playground hosts it until graduation, when the org + * projects route takes over; that move changes this constant and nothing else. + */ +export const PROJECT_HOME_BASE = '/playground/org' + +export type ProjectSection = 'dashboard' | 'changelog' | 'issues' | 'environments' + +/** Href of one section of the project main view, for the workspace the viewer is in. */ +export function projectHomeHref(workspaceId: string, section: ProjectSection = 'dashboard') { + return `${PROJECT_HOME_BASE}/p/${workspaceId}/${section}` +} + +interface ProjectNavSection { + id: ProjectSection + label: string + icon: ComponentType<{ className?: string }> +} + +/** The project main view's sections, top-down, as the main view's own chips read them. */ +export const PROJECT_NAV_SECTIONS: readonly ProjectNavSection[] = [ + { id: 'dashboard', label: 'Dashboard', icon: Layout }, + { id: 'changelog', label: 'Changelog', icon: Rss }, + { id: 'issues', label: 'Issues', icon: ListChecks }, + { id: 'environments', label: 'Environments', icon: Server }, +] + +export type BuildSectionId = + | 'integrations' + | 'workflows' + | 'files' + | 'logs' + | 'tables' + | 'knowledge' + | 'settings' + +interface BuildNavSection { + id: BuildSectionId + label: string + icon: ComponentType<{ className?: string }> + /** Path under `/workspace//` the section opens. */ + segment: string + /** Sibling path prefixes under `/workspace//` that keep the section active. */ + activeSegments?: readonly string[] +} + +/** + * The Build sections in the order the project view lists them, which is also the order the + * main view's Build tables and the playground's `WORKSPACE_SECTIONS` read. One constant so + * every listing of these sections follows it. + */ +export const BUILD_NAV_SECTIONS: readonly BuildNavSection[] = [ + { + id: 'integrations', + label: 'Integrations', + icon: Integration, + segment: 'integrations', + activeSegments: ['skills'], + }, + { id: 'workflows', label: 'Workflows', icon: Workflow, segment: 'w' }, + { id: 'files', label: 'Files', icon: Files, segment: 'files' }, + { id: 'logs', label: 'Logs', icon: Library, segment: 'logs' }, + { id: 'tables', label: 'Tables', icon: Table, segment: 'tables' }, + { id: 'knowledge', label: 'Knowledge', icon: Database, segment: 'knowledge' }, + { + id: 'settings', + label: 'Settings', + icon: Settings, + segment: 'settings/general', + activeSegments: ['settings'], + }, +] diff --git a/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/index.ts b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/index.ts new file mode 100644 index 00000000000..b92a05e3ad8 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/index.ts @@ -0,0 +1,7 @@ +export { + BUILD_NAV_SECTIONS, + PROJECT_HOME_BASE, + PROJECT_NAV_SECTIONS, + projectHomeHref, +} from '@/app/workspace/[workspaceId]/components/project-build-sidebar/constants' +export { ProjectBuildSidebar } from '@/app/workspace/[workspaceId]/components/project-build-sidebar/project-build-sidebar' diff --git a/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/project-build-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/project-build-sidebar.tsx new file mode 100644 index 00000000000..a807b20bd27 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/components/project-build-sidebar/project-build-sidebar.tsx @@ -0,0 +1,611 @@ +'use client' + +import { memo, useCallback, useMemo, useRef, useState } from 'react' +import { + Chip, + ChipLink, + chipVariants, + cn, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, + OverflowText, + scrollFadeAttributes, + scrollFadeClass, + useScrollEdges, +} from '@sim/emcn' +import { + Building, + Check, + ChevronDown, + ChevronLeft, + PanelLeft, + Plus, + Search, + Server, + SquarePen, +} from '@sim/emcn/icons' +import { useParams, usePathname, useRouter } from 'next/navigation' +import { usePostHog } from 'posthog-js/react' +import { IdentityTile } from '@/components/identity-tile/identity-tile' +import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' +import { useDeploymentShape } from '@/lib/core/config/deployment-shape' +import { isStatusNoticePreviewEnabled } from '@/lib/core/config/env-flags' +import { isMacPlatform } from '@/lib/core/utils/platform' +import { DOCS_URL, SLACK_COMMUNITY_URL } from '@/lib/help-links' +import { captureEvent } from '@/lib/posthog/client' +import { parseEnvironmentName, resolveProjectLineages } from '@/lib/projects' +import { + BUILD_NAV_SECTIONS, + PROJECT_NAV_SECTIONS, + projectHomeHref, +} from '@/app/workspace/[workspaceId]/components/project-build-sidebar/constants' +import { useSidebarChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' +import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' +import { + allNavigationItems, + type SettingsSection, +} from '@/app/workspace/[workspaceId]/settings/navigation' +import { + ChatNavigationLink, + isNavItemActive, + SettingsSidebar, + SidebarFooter, + SidebarNavChip, + type SidebarNavItemData, + SidebarSection, + SidebarTooltip, + StatusNotice, + WorkspaceSidebarModals, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { + SIDEBAR_DIVIDER_PAD_ABOVE_CLASS, + SIDEBAR_DIVIDER_PAD_BELOW_CLASS, + SIDEBAR_ITEM_GAP_CLASS, + SIDEBAR_RAIL_CHIP_CLASS, + SIDEBAR_SECTION_GAP_CLASS, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' +import { useSidebarResize } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { + type SidebarChat, + useWorkspaceSidebarServices, +} from '@/app/workspace/[workspaceId]/w/components/sidebar/use-workspace-sidebar-services' +import { useWorkspaceAccessRequestFeatures } from '@/ee/access-requests/components/permission-access-boundary' +import { useUserProfile } from '@/hooks/queries/user-profile' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' +import { usePermissionConfig } from '@/hooks/use-permission-config' +import { useSettingsNavigation } from '@/hooks/use-settings-navigation' +import { useSearchModalStore } from '@/stores/modals/search/store' +import { useSidebarStore } from '@/stores/sidebar/store' + +/** How many chats show before "See more", matching the workspace sidebar. */ +const CHAT_PREVIEW_COUNT = 5 + +/** + * Opts a control out of the desktop shell's window-drag region. The header row is + * draggable chrome, so anything clickable inside it has to say so or the click is + * swallowed by the drag handler. + */ +const DRAG_EXEMPT_CLASS = '[-webkit-app-region:no-drag]' + +interface ProjectBuildSidebarProps { + organizationHref: string | null +} + +/** + * The workspace rail while the org project view is on: the project's Build view. + * + * Same chrome as the workspace `Sidebar` (collapse, peek, resize, footer, settings + * sub-navigation) and the same invisible services underneath it through + * {@link useWorkspaceSidebarServices}, but the rail lists the project instead of the + * workspace: a back chip to the project main view, the environment the workspace plays in + * its fork lineage, the main view's sections, the flat Build sections, and the chats. No + * resource trees: every Build page owns its own folder tree in the content area. + */ +export const ProjectBuildSidebar = memo(function ProjectBuildSidebar({ + organizationHref, +}: ProjectBuildSidebarProps) { + const { isCollapsed: isCollapsedProp, isPeeking } = useSidebarChrome() + const isCollapsed = isCollapsedProp && !isPeeking + const params = useParams() + const workspaceId = params.workspaceId as string + const workflowId = params.workflowId as string | undefined + const router = useRouter() + const pathname = usePathname() + + const scrollContainerRef = useRef(null) + const scrollContentRef = useRef(null) + + const posthog = usePostHog() + const { data: profile } = useUserProfile() + const hostContext = useWorkspaceHostContext() + const { hosted } = useDeploymentShape() + const { config: permissionConfig } = usePermissionConfig() + const accessRequests = useWorkspaceAccessRequestFeatures() + const accessRequestsEnabled = accessRequests.data?.enabled === true + const { getSettingsHref, navigateToSettings } = useSettingsNavigation() + const openSearchModal = useSearchModalStore((state) => state.open) + const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) + const { data: workspaces } = useWorkspacesQuery() + + const services = useWorkspaceSidebarServices({ workspaceId, workflowId }) + const { chats, chatsLoading, chatsEnabled } = services + + const { handlePointerDown } = useSidebarResize() + const [visibleChatCount, setVisibleChatCount] = useState(CHAT_PREVIEW_COUNT) + + const scrollEdges = useScrollEdges(scrollContainerRef, { + contentRef: scrollContentRef, + enabled: !isCollapsed, + }) + + const isMac = isMacPlatform() + const showCollapsedTooltips = isCollapsed + const isOnSettingsPage = pathname?.startsWith(`/workspace/${workspaceId}/settings`) ?? false + + /** + * The project this workspace belongs to. Until the workspace list arrives the route's own + * workspace stands in as a one-environment project, so the header never renders blank. + */ + const project = useMemo(() => { + const lineage = workspaces ? resolveProjectLineages(workspaces).get(workspaceId) : undefined + if (lineage) { + const root = workspaces?.find((workspace) => workspace.id === lineage.rootId) + return { ...lineage, logoUrl: root?.logoUrl ?? null } + } + const { base, environment } = parseEnvironmentName(hostContext.workspace.name) + return { + name: base, + environment: environment ?? 'Prod', + environments: [{ workspaceId, label: environment ?? 'Prod' }], + logoUrl: null, + } + }, [workspaces, workspaceId, hostContext.workspace.name]) + + /** The page the viewer is on, relative to the workspace, so a switch lands on its peer. */ + const workspaceSubPath = useMemo(() => { + const base = `/workspace/${workspaceId}` + return pathname?.startsWith(base) ? pathname.slice(base.length) : '' + }, [pathname, workspaceId]) + + const projectNavItems = useMemo( + (): SidebarNavItemData[] => + PROJECT_NAV_SECTIONS.map((section) => ({ + id: section.id, + label: section.label, + icon: section.icon, + href: projectHomeHref(workspaceId, section.id), + })), + [workspaceId] + ) + + const buildNavItems = useMemo(() => { + const base = `/workspace/${workspaceId}` + const hiddenBySection: Partial> = { + integrations: permissionConfig.hideIntegrationsTab, + files: permissionConfig.hideFilesTab, + tables: permissionConfig.hideTablesTab, + knowledge: permissionConfig.hideKnowledgeBaseTab, + } + return BUILD_NAV_SECTIONS.map((section): SidebarNavItemData & { hidden: boolean } => { + const restricted = hiddenBySection[section.id] === true + return { + id: section.id, + label: section.label, + icon: section.icon, + href: `${base}/${section.segment}`, + additionalActivePaths: section.activeSegments?.map((segment) => `${base}/${segment}`), + restricted, + hidden: restricted && !accessRequestsEnabled, + } + }).filter((item) => !item.hidden) + }, [ + workspaceId, + permissionConfig.hideIntegrationsTab, + permissionConfig.hideFilesTab, + permissionConfig.hideTablesTab, + permissionConfig.hideKnowledgeBaseTab, + accessRequestsEnabled, + ]) + + const newChatItem = useMemo( + (): SidebarNavItemData => ({ + id: 'new-chat', + label: 'New chat', + icon: SquarePen, + href: `/workspace/${workspaceId}/home`, + restricted: permissionConfig.hideCopilot, + }), + [workspaceId, permissionConfig.hideCopilot] + ) + + const handleOpenSettings = (section: SettingsSection) => { + navigateToSettings({ section }) + } + + const profileNavigationLinks = allNavigationItems + .filter( + ({ id }) => + id === 'teammates' || + id === 'recently-deleted' || + (id === 'billing' && canViewWorkspaceBillingSettings(hostContext, profile?.id)) + ) + .map(({ id, label, icon }) => ({ + label, + icon, + href: getSettingsHref({ section: id }), + onNavigate: () => handleOpenSettings(id), + })) + + if (organizationHref) { + profileNavigationLinks.push({ + label: 'Organization', + icon: Building, + href: organizationHref, + onNavigate: () => router.push(organizationHref), + }) + } + + const handleSwitchEnvironment = useCallback( + (targetWorkspaceId: string) => { + if (targetWorkspaceId === workspaceId) return + router.push(`/workspace/${targetWorkspaceId}${workspaceSubPath}`) + }, + [router, workspaceId, workspaceSubPath] + ) + + const handleSeeMoreChats = useCallback( + () => setVisibleChatCount((prev) => prev + CHAT_PREVIEW_COUNT), + [] + ) + const handleSeeLessChats = useCallback(() => setVisibleChatCount(CHAT_PREVIEW_COUNT), []) + + const handleEdgeKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (isCollapsed && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault() + toggleCollapsed() + } + }, + [isCollapsed, toggleCollapsed] + ) + + const handleOpenDocs = () => { + window.open(DOCS_URL, '_blank', 'noopener,noreferrer') + captureEvent(posthog, 'docs_opened', { source: 'help_menu' }) + } + + const handleOpenSlackCommunity = () => { + window.open(SLACK_COMMUNITY_URL, '_blank', 'noopener,noreferrer') + captureEvent(posthog, 'slack_community_opened', { source: 'help_menu' }) + } + + const environmentChip = ( + + {project.environment} + + ) + + return ( + <> +
    + + + {/* Not on the peek card: the resize hook writes an inline `--sidebar-width` that + out-specifies the `[data-peek]` rule, stranding the card at a stale width. */} + {!isPeeking && ( +
    + )} +
    + + + + ) +}) + +interface ProjectChatItemProps { + chat: SidebarChat + isCurrentRoute: boolean + showCollapsedTooltips: boolean +} + +/** One chat row: the same link and prefetch behavior as the workspace sidebar's chats. */ +const ProjectChatItem = memo(function ProjectChatItem({ + chat, + isCurrentRoute, + showCollapsedTooltips, +}: ProjectChatItemProps) { + const showStatusDot = chat.isActive || (!isCurrentRoute && chat.isUnread) + return ( + + + + {showStatusDot && ( + + + ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 2885e0fd8f1..8a5d61b8b84 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -601,6 +601,11 @@ export interface UseChatOptions { projectsDesktopTabs?: boolean /** Fired when the server's `traceparent` response header arrives, before any stream content. */ onRequestStarted?: (info: { requestId: string; userMessageId: string }) => void + /** + * Rewrite the browser URL to the chat's canonical route once a new chat is created. + * Defaults to true; a surface that owns its own URL (a side panel) passes false. + */ + syncChatUrl?: boolean } interface ActiveStreamRecovery { @@ -624,6 +629,7 @@ export function getMothershipUseChatOptions( | 'activeResourceState' | 'onRequestStarted' | 'requestMode' + | 'syncChatUrl' > = {} ): UseChatOptions { return { @@ -690,6 +696,8 @@ export function useChat( const pendingStopModeRef = useRef(null) const workflowIdRef = useRef(options?.workflowId) workflowIdRef.current = options?.workflowId + const syncChatUrlRef = useRef(options?.syncChatUrl !== false) + syncChatUrlRef.current = options?.syncChatUrl !== false const onToolResultRef = useRef(options?.onToolResult) onToolResultRef.current = options?.onToolResult const onTitleUpdateRef = useRef(options?.onTitleUpdate) @@ -1219,6 +1227,7 @@ export function useChat( } if ( options?.replaceHomeHistory && + syncChatUrlRef.current && !selectedChatId && !workflowIdRef.current && typeof window !== 'undefined' diff --git a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx index 64ba769d9d1..ace6a996037 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.test.tsx @@ -68,6 +68,10 @@ vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/sidebar', () => ({ Sidebar: () => null, })) +vi.mock('@/app/workspace/[workspaceId]/components/project-build-sidebar', () => ({ + ProjectBuildSidebar: () => null, +})) + vi.mock('@/app/workspace/[workspaceId]/components/workspace-access-denied', () => ({ WorkspaceAccessDenied: () =>
    Workspace access denied
    , })) diff --git a/apps/sim/app/workspace/[workspaceId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/layout.tsx index 2ab2c202d72..ae6f2d8b5a5 100644 --- a/apps/sim/app/workspace/[workspaceId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/layout.tsx @@ -6,9 +6,11 @@ import { getActiveOrganizationId } from '@/lib/auth/session-response' import { isDashboardsEnabled } from '@/lib/dashboards/feature-flag' import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags' import { resolveOrganizationEntryPath } from '@/lib/navigation/resolve-app-entry' +import { isOrgProjectViewEnabled } from '@/lib/projects/feature-flag' import { isTableRowTtlEnabled } from '@/lib/table/ttl-availability' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' +import { ProjectBuildSidebar } from '@/app/workspace/[workspaceId]/components/project-build-sidebar' import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired' import { WorkspaceAccessDenied } from '@/app/workspace/[workspaceId]/components/workspace-access-denied' import { WorkspaceChrome } from '@/app/workspace/[workspaceId]/components/workspace-chrome' @@ -60,6 +62,7 @@ export default async function WorkspaceLayout({ planModeEnabled, organizationHref, dashboardsEnabled, + orgProjectViewEnabled, ] = await Promise.all([ cookies(), hostContext.hostOrganizationId @@ -77,6 +80,7 @@ export default async function WorkspaceLayout({ isPlanModeEnabled(), resolveOrganizationEntryPath(session), isDashboardsEnabled(hostContext.hostOrganizationId), + isOrgProjectViewEnabled(hostContext.hostOrganizationId), prefetchWorkspaceAccess(queryClient, workspaceId, { kind: 'session', userId: session.user.id, @@ -93,6 +97,7 @@ export default async function WorkspaceLayout({ 'table-row-ttl': tableRowTtlEnabled, 'mothership-model-selector': modelSelectorEnabled, 'mothership-plan-mode': planModeEnabled, + 'org-project-view': orgProjectViewEnabled, }} > @@ -113,7 +118,13 @@ export default async function WorkspaceLayout({ } + sidebar={ + orgProjectViewEnabled ? ( + + ) : ( + + ) + } initialSidebarCollapsed={initialSidebarCollapsed} > {children} diff --git a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx index d27ba042643..a76db6def19 100644 --- a/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx +++ b/apps/sim/app/workspace/[workspaceId]/providers/feature-flags-provider.tsx @@ -7,6 +7,7 @@ export interface WorkspaceFeatureFlags { 'table-row-ttl': boolean 'mothership-model-selector': boolean 'mothership-plan-mode': boolean + 'org-project-view': boolean } const FeatureFlagsContext = createContext(null) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-location-bar.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-location-bar.tsx new file mode 100644 index 00000000000..d136c4465cf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-location-bar.tsx @@ -0,0 +1,57 @@ +'use client' + +import { Search } from '@sim/emcn/icons' +import { useParams, useRouter } from 'next/navigation' +import { + breadcrumbFolderChain, + folderBreadcrumbItems, +} from '@/app/workspace/[workspaceId]/components/folders' +import { ResourceHeader } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header' +import { useFeatureFlag } from '@/app/workspace/[workspaceId]/providers/feature-flags-provider' +import { useFolderMap } from '@/hooks/queries/folders' +import { useWorkflowMap } from '@/hooks/queries/workflows' +import { useSearchModalStore } from '@/stores/modals/search/store' + +/** Keeps the open workflow identifiable when Build uses section-level navigation. */ +export function WorkflowLocationBar() { + const enabled = useFeatureFlag('org-project-view') + return enabled ? : null +} + +function WorkflowLocationBarContent() { + const { workspaceId, workflowId } = useParams<{ workspaceId: string; workflowId: string }>() + const router = useRouter() + const { data: workflows } = useWorkflowMap(workspaceId) + const { data: folders } = useFolderMap(workspaceId) + const openSearch = useSearchModalStore((state) => state.open) + const workflow = workflows?.[workflowId] + const ancestors = breadcrumbFolderChain( + workflow?.folderId, + new Map(Object.values(folders ?? {}).map((folder) => [folder.id, folder])) + ) + const breadcrumbs = folderBreadcrumbItems({ + rootLabel: 'Workflows', + breadcrumbs: ancestors, + onNavigate: (folderId) => { + const query = folderId ? `?folderId=${encodeURIComponent(folderId)}` : '' + router.push(`/workspace/${workspaceId}/w${query}`) + }, + trailing: [{ label: workflow?.name ?? 'Workflow', onClick: openSearch }], + }) + + return ( +
    + +
    + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx index 73078feb796..9fe99ccf9bf 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/layout.tsx @@ -1,9 +1,13 @@ import { ErrorBoundary } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/error' +import { WorkflowLocationBar } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/workflow-location-bar' export default function WorkflowLayout({ children }: { children: React.ReactNode }) { return (
    - {children} + +
    + {children} +
    ) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/first-workflow-redirect/first-workflow-redirect.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/first-workflow-redirect/first-workflow-redirect.tsx new file mode 100644 index 00000000000..36c981a263c --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/first-workflow-redirect/first-workflow-redirect.tsx @@ -0,0 +1,122 @@ +'use client' + +import { useEffect } from 'react' +import { Chip } from '@sim/emcn' +import { createLogger } from '@sim/logger' +import { ReactFlowProvider } from '@xyflow/react' +import { useParams, useRouter } from 'next/navigation' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { Panel, Terminal } from '@/app/workspace/[workspaceId]/w/[workflowId]/components' +import { useWorkflowOperations } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' +import { useWorkflows } from '@/hooks/queries/workflows' + +const logger = createLogger('WorkflowsPage') + +function Spinner() { + return ( +
    + ) +} + +/** + * The `/w` route's classic behavior: land on the workspace's first workflow. Renders the + * canvas chrome while the list resolves so the redirect never flashes an empty frame, and + * offers a way out when the workspace has no workflows at all. + */ +export function FirstWorkflowRedirect() { + const router = useRouter() + const params = useParams() + const workspaceId = params.workspaceId as string + + const { data: workflows = [], isLoading, isError, isPlaceholderData } = useWorkflows(workspaceId) + const { handleCreateWorkflow, isCreatingWorkflow } = useWorkflowOperations({ workspaceId }) + const { canEdit, isLoading: permissionsLoading } = useUserPermissionsContext() + + // An id rather than the filtered array: `data` defaults to a fresh `[]` while + // the query has no data, so an array dependency would re-fire this on every + // render — exactly during the load this page exists to cover. + const firstWorkflowId = workflows.find((w) => w.workspaceId === workspaceId)?.id + const isResolving = isLoading || isPlaceholderData + + useEffect(() => { + if (isResolving) return + + if (isError) { + logger.error('Failed to load workflows for workspace') + return + } + + if (firstWorkflowId) { + router.replace(`/workspace/${workspaceId}/w/${firstWorkflowId}`) + } + }, [isResolving, isError, firstWorkflowId, workspaceId, router]) + + /** + * A workspace can legitimately reach zero workflows — deleting the last one, + * archiving them all, or creating a workspace with `skipDefaultWorkflow`. This + * is the terminal state for those paths now that the chat composer is no + * longer a landing option, so it has to offer a way out rather than spin. + */ + const isEmpty = !isResolving && !isError && !firstWorkflowId + const canCreate = !permissionsLoading && canEdit + + return ( +
    +
    +
    + {isError ? ( + // This is the landing route now, so a failed list fetch would + // otherwise spin forever with nothing but a log line. +
    +
    +

    Couldn't load workflows

    +

    Check your connection and try again.

    +
    + router.refresh()}> + Retry + +
    + ) : isEmpty ? ( +
    +
    +

    No workflows yet

    +

    + {canCreate + ? 'Create one to start building.' + : 'Ask a workspace admin to create one.'} +

    +
    + {/* The create mutation navigates optimistically, so offering it + without write access would strand a read-only member on a + workflow the server declined to create. */} + {canCreate && ( + + {isCreatingWorkflow ? 'Creating…' : 'Create workflow'} + + )} +
    + ) : ( + + )} +
    + + + +
    + +
    + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts index e735e674e89..56cf307c647 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts @@ -19,3 +19,4 @@ export { SidebarTooltip } from './sidebar-tooltip' export { StatusNotice } from './status-notice' export { WorkflowList } from './workflow-list' export { WorkspaceHeader } from './workspace-header' +export { WorkspaceSidebarModals } from './workspace-sidebar-modals' diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx index 20fa333126e..7878e50fdfa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu.tsx @@ -6,12 +6,16 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@sim/emcn' import { Download, Duplicate, Eye, + FolderInput, FolderPlus, ImageUp, Lock, @@ -26,9 +30,15 @@ import { Unlock, X, } from '@sim/emcn/icons' +import type { MoveOptionNode } from '@/app/workspace/[workspaceId]/components/folders' +import { renderMoveOptions } from '@/app/workspace/[workspaceId]/components/folders' import { selectionActionLabel } from '@/app/workspace/[workspaceId]/components/resource/selection-label' interface ContextMenuProps { + /** Files the item under another folder; the value is a folder id or the root sentinel. */ + onMove?: (optionValue: string) => void + /** Destinations for `onMove`; the submenu renders only when this is non-empty. */ + moveOptions?: MoveOptionNode[] isOpen: boolean position: { x: number; y: number } menuRef: React.RefObject @@ -131,6 +141,8 @@ export function ContextMenu({ onCreate, onCreateFolder, onDuplicate, + onMove, + moveOptions, onExport, onDelete, onCloseTab, @@ -170,6 +182,8 @@ export function ContextMenu({ disableUploadLogo = false, selectedCount = 1, }: ContextMenuProps) { + /** `Move to` needs a NON-EMPTY `moveOptions`, not just the handler, to earn its row. */ + const hasMoveAction = !!(onMove && moveOptions && moveOptions.length > 0) const hasActionsAboveDestructive = onCopyLink || (showOpenInNewTab && onOpenInNewTab) || @@ -182,6 +196,7 @@ export function ContextMenu({ (showLock && onToggleLock) || (showUploadLogo && onUploadLogo) || (showDuplicate && onDuplicate) || + hasMoveAction || (showExport && onExport) const hasDestructiveSection = (showLeave && onLeave) || @@ -366,6 +381,20 @@ export function ContextMenu({ {selectionActionLabel('Duplicate', selectedCount)} )} + {hasMoveAction && onMove && moveOptions && ( + + + + {selectionActionLabel('Move', selectedCount, 'Move to')} + + + {renderMoveOptions(moveOptions, (value) => { + onMove(value) + onClose() + })} + + + )} {showExport && onExport && (