From 85488217b1b8ef14f3d2563a40557bec5f0851b2 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 17:11:18 -0700 Subject: [PATCH 01/15] feat(playground): wire the org project view to real workspaces Projects are the logged-in user's real workspaces; chats, workflows, files, tables, knowledge bases, logs and integrations come from the workspace's own React Query hooks and open the real pages. The side chat and new-chat home run the real Sim Chat over useChat, with a syncChatUrl option so the panel keeps its own URL. Dashboards render real dashboard files live plus a built-in Runs dashboard from the logs stats route. Issues and Changelog stay mock through per-project overlay packs matched by workspace name, and issue resource chips resolve to the real resource with the same name. Co-Authored-By: Claude Fable 5.1 --- .../playground/org/components/agent-run.tsx | 2 +- .../playground/org/components/changelog.tsx | 22 +- .../org/components/dashboard-file.tsx | 30 ++ .../playground/org/components/issue-page.tsx | 90 +++- .../playground/org/components/issues-list.tsx | 19 +- .../playground/org/components/live-chat.tsx | 99 ++++ .../org/components/new-chat-home.tsx | 58 ++- .../org/components/project-settings.tsx | 253 ++------- .../org/components/project-shell.tsx | 116 +++-- .../org/components/proto-dashboard.tsx | 88 +++- .../playground/org/components/proto-page.tsx | 116 +++-- .../org/components/proto-sidebar.tsx | 173 ++++--- .../org/components/resource-section.tsx | 253 ++++++++- .../org/components/runs-dashboard.tsx | 194 +++++++ .../org/components/workspace-view.tsx | 51 +- apps/sim/app/playground/org/layout.tsx | 23 +- .../app/playground/org/lib/changelog-data.ts | 4 + .../sim/app/playground/org/lib/claims-data.ts | 478 ++++++++++++++++++ apps/sim/app/playground/org/lib/mock-data.ts | 45 +- apps/sim/app/playground/org/lib/project.ts | 49 ++ apps/sim/app/playground/org/lib/routes.ts | 38 +- .../app/playground/org/lib/search-params.ts | 17 +- .../org/lib/use-project-resources.ts | 47 ++ .../org/p/[workspaceId]/[[...rest]]/page.tsx | 18 + .../playground/org/p/[workspaceId]/layout.tsx | 21 + .../[workspaceId]/home/hooks/use-chat.ts | 9 + 26 files changed, 1802 insertions(+), 511 deletions(-) create mode 100644 apps/sim/app/playground/org/components/dashboard-file.tsx create mode 100644 apps/sim/app/playground/org/components/live-chat.tsx create mode 100644 apps/sim/app/playground/org/components/runs-dashboard.tsx create mode 100644 apps/sim/app/playground/org/lib/claims-data.ts create mode 100644 apps/sim/app/playground/org/lib/project.ts create mode 100644 apps/sim/app/playground/org/lib/use-project-resources.ts create mode 100644 apps/sim/app/playground/org/p/[workspaceId]/[[...rest]]/page.tsx create mode 100644 apps/sim/app/playground/org/p/[workspaceId]/layout.tsx diff --git a/apps/sim/app/playground/org/components/agent-run.tsx b/apps/sim/app/playground/org/components/agent-run.tsx index 3dccf318f60..bd928058cd7 100644 --- a/apps/sim/app/playground/org/components/agent-run.tsx +++ b/apps/sim/app/playground/org/components/agent-run.tsx @@ -120,7 +120,7 @@ export function AgentRun({ work, workspaceId }: AgentRunProps) { diff --git a/apps/sim/app/playground/org/components/changelog.tsx b/apps/sim/app/playground/org/components/changelog.tsx index 35fc176d62c..59e6cfda136 100644 --- a/apps/sim/app/playground/org/components/changelog.tsx +++ b/apps/sim/app/playground/org/components/changelog.tsx @@ -15,7 +15,7 @@ import { 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 { protoRoutes } from '@/app/playground/org/lib/routes' const RELEASE_YEAR = 2026 @@ -26,12 +26,12 @@ 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) +export function Changelog({ project }: { project: Project }) { + const draft = DRAFTS.find((d) => d.workspaceId === project.mock.id) const [released, setReleased] = useState(null) const shipped = [ ...(released ? [released] : []), - ...CHANGELOG.filter((entry) => entry.workspaceId === workspace.id), + ...CHANGELOG.filter((entry) => entry.workspaceId === project.mock.id), ] const release = (draftRelease: DraftRelease) => { @@ -66,8 +66,8 @@ 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. + What’s about to ship in {project.name}, what Sim is still working on, and what already + changed.

@@ -81,7 +81,7 @@ export function Changelog({ workspace }: { workspace: Workspace }) { )} {shipped.map((entry) => ( - + ))} {!draft && shipped.length === 0 && ( @@ -133,7 +133,7 @@ function NextRelease({ draft, released, onRelease }: NextReleaseProps) { ) : (
    {changes.map((change) => ( - + ))}
)} @@ -145,7 +145,7 @@ function NextRelease({ draft, released, onRelease }: NextReleaseProps) { {draft.running.map((work) => (
  • @@ -198,7 +198,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 +227,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/issue-page.tsx b/apps/sim/app/playground/org/components/issue-page.tsx index 65a9310810e..d1b8036e8a9 100644 --- a/apps/sim/app/playground/org/components/issue-page.tsx +++ b/apps/sim/app/playground/org/components/issue-page.tsx @@ -39,9 +39,10 @@ import { STATUS_LABELS, STATUS_ORDER, ticketUrl, - type Workspace, } from '@/app/playground/org/lib/mock-data' -import { protoRoutes } from '@/app/playground/org/lib/routes' +import type { Project } from '@/app/playground/org/lib/project' +import { protoRoutes, workspaceRoutes } from '@/app/playground/org/lib/routes' +import { useProjectResources } from '@/app/playground/org/lib/use-project-resources' const RESOURCE_ICONS = { table: Table, @@ -52,12 +53,13 @@ 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 resolveHref = useResourceHrefs(project.id) const [status, setStatus] = useState(issue.status) const [priority, setPriority] = useState(issue.priority) const [activity, setActivity] = useState(issue.activity) @@ -68,8 +70,8 @@ export function IssuePage({ workspace, issue }: IssuePageProps) { return (
    - - {workspace.name} + + {project.name} / {issue.key} @@ -224,7 +226,7 @@ export function IssuePage({ workspace, issue }: IssuePageProps) { {chats.map((chat) => ( @@ -288,21 +290,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 +430,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..43f4eda5e0d 100644 --- a/apps/sim/app/playground/org/components/issues-list.tsx +++ b/apps/sim/app/playground/org/components/issues-list.tsx @@ -2,18 +2,13 @@ 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 { ISSUES, type Issue, STATUS_LABELS, STATUS_ORDER } from '@/app/playground/org/lib/mock-data' +import type { Project } from '@/app/playground/org/lib/project' import { protoRoutes } from '@/app/playground/org/lib/routes' /** 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 = ISSUES.filter((issue) => issue.workspaceId === project.mock.id) if (!issues.length) return

    No issues yet.

    return ( @@ -32,7 +27,7 @@ export function IssuesList({ workspace }: { workspace: Workspace }) {
    {inStatus.map((issue) => ( - + ))}
    @@ -42,10 +37,10 @@ export function IssuesList({ workspace }: { workspace: Workspace }) { ) } -function IssueRow({ issue }: { issue: Issue }) { +function IssueRow({ issue, projectId }: { issue: Issue; projectId: string }) { return ( 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 + sentInitialRef.current = true + void sendMessage(initialMessage) + onInitialMessageSent?.() + }, [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..ed915a6ff3c 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,25 @@ 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 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() { const router = useRouter() + const { data: session } = useSession() + const { 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] return (
    @@ -36,7 +41,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 +106,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-settings.tsx b/apps/sim/app/playground/org/components/project-settings.tsx index a5475cad829..4886775eeae 100644 --- a/apps/sim/app/playground/org/components/project-settings.tsx +++ b/apps/sim/app/playground/org/components/project-settings.tsx @@ -1,20 +1,24 @@ '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, ChipLink } from '@sim/emcn' +import { ArrowUpRight } from '@sim/emcn/icons' +import { JiraIcon, LinearIcon } from '@/components/icons' +import type { WorkspaceSettingsSection } from '@/components/settings/navigation' +import type { Project } from '@/app/playground/org/lib/project' +import { workspaceRoutes } from '@/app/playground/org/lib/routes' import { SETTINGS_NAV } from '@/app/playground/org/lib/settings-nav' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' 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; the settings list itself lives in the sidebar. The project's General + * page is the prototype's own; every other section is the real workspace settings page. + */ +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 +31,41 @@ export function ProjectSettings({ workspace, sectionId }: ProjectSettingsProps)

    {active.label}

    {active.description}

    - {PRIMARY_ACTION[active.id] && ( - - {PRIMARY_ACTION[active.id]} - - )} - + {active.group === 'project' ? ( + + ) : ( + + Open {active.label} in workspace settings + + )} ) } -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 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', project.mock.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..67a640c1632 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 { MockComposer } from '@/app/playground/org/components/mock-composer' +import { LiveChat } from '@/app/playground/org/components/live-chat' import { DRAFTS } from '@/app/playground/org/lib/changelog-data' -import { CHATS, type Chat, type Workspace } from '@/app/playground/org/lib/mock-data' +import { CHATS, type Chat } from '@/app/playground/org/lib/mock-data' +import type { Project } from '@/app/playground/org/lib/project' 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,69 @@ 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 { data: chats } = useMothershipChats(project.id) + const realChat = chats?.find((chat) => chat.id === chatId) + /** Chats from the overlay pack (changelog and issue stories) keep their canned threads. */ + const mockChat: Chat | undefined = CHATS.find( + (chat) => chat.id === chatId && chat.workspaceId === project.mock.id + ) + const runs = mockChat + ? (DRAFTS.find((draft) => draft.workspaceId === project.mock.id)?.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 ? ( + ) : ( - + )}
    ) diff --git a/apps/sim/app/playground/org/components/proto-dashboard.tsx b/apps/sim/app/playground/org/components/proto-dashboard.tsx index e730431939a..0e09ccc2b67 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,47 @@ 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 { 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 } 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) - return ( -

    - No dashboards yet. Ask Sim to build one from this workspace’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 { dashboardFiles, filesPending } = useProjectResources(project.id) + + if (filesPending) + return

    Loading…

    + + const options: DashboardOption[] = [ + ...dashboardFiles.map((file) => ({ id: file.id, title: file.name, kind: 'file' as const })), + { id: RUNS_ID, title: 'Runs', kind: 'runs' }, + ...(dashboardFiles.length + ? [] + : project.mock.dashboards.map((id) => ({ + id, + title: `${MOCK_DASHBOARDS[id]?.title ?? id} (sample)`, + kind: 'sample' as const, + }))), + ] + const current = options.find((option) => option.id === selected) ?? options[0] const title = ( @@ -37,18 +60,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 +83,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(project.id) + 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..33577f4d571 100644 --- a/apps/sim/app/playground/org/components/proto-page.tsx +++ b/apps/sim/app/playground/org/components/proto-page.tsx @@ -1,15 +1,18 @@ 'use client' +import type { ReactNode } from 'react' import { notFound } from 'next/navigation' -import { ChatThread } from '@/app/playground/org/components/chat-thread' 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 { issueByKey } from '@/app/playground/org/lib/mock-data' +import { type Project, useProject, useProjects } from '@/app/playground/org/lib/project' 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. */ +/** Client-side router for the prototype's catch-all and project routes. */ export function ProtoPage({ slug }: { slug?: string[] }) { const route = parseProtoRoute(slug) if (!route) notFound() @@ -21,45 +24,88 @@ export function ProtoPage({ slug }: { slug?: string[] }) { return case 'connectors': return - case 'chat': { - const chat = CHATS.find((c) => c.id === route.chatId) - if (!chat) notFound() + case 'chat': + return + case 'workspace': return ( -
    -
    - {chat.title} -
    - -
    + + {(project) => ( + + + + )} + ) - } - case 'workspace': { - const workspace = WORKSPACES.find((w) => w.id === route.workspaceId) - if (!workspace) notFound() + case 'issue': return ( - - - + + {(project) => { + const issue = issueByKey(route.issueKey) + if (!issue || issue.workspaceId !== project.mock.id) notFound() + return ( + + + + ) + }} + ) - } - case 'issue': { - const workspace = WORKSPACES.find((w) => w.id === route.workspaceId) - const issue = issueByKey(route.issueKey) - if (!workspace || !issue || issue.workspaceId !== workspace.id) notFound() - return ( - - - - ) - } } } +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() +} + +/** 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-sidebar.tsx b/apps/sim/app/playground/org/components/proto-sidebar.tsx index 330be70ac51..b95af167ecc 100644 --- a/apps/sim/app/playground/org/components/proto-sidebar.tsx +++ b/apps/sim/app/playground/org/components/proto-sidebar.tsx @@ -23,20 +23,15 @@ import { Search, Settings, } from '@sim/emcn/icons' +import { formatRelativeTime } from '@sim/utils/formatting' import Link from 'next/link' import { usePathname } from 'next/navigation' import { useQueryStates } from 'nuqs' import { IdentityTile } from '@/components/identity-tile/identity-tile' +import type { WorkspaceSettingsSection } from '@/components/settings/navigation' +import { useActiveOrganization, useSession } from '@/lib/auth/auth-client' 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 { type Project, useProjects } from '@/app/playground/org/lib/project' import { fullViewProject, MAIN_SECTION_IDS, @@ -45,6 +40,7 @@ import { settingsProject, WORKSPACE_SECTIONS, type WorkspaceSection, + workspaceRoutes, } from '@/app/playground/org/lib/routes' import { protoParsers } from '@/app/playground/org/lib/search-params' import { @@ -67,11 +63,14 @@ 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 }, @@ -79,14 +78,24 @@ const NAV_ITEMS: SidebarNavItemData[] = [ { 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', '') +} + +/** 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 } = useProjects() + const { data: organization } = useActiveOrganization() + const { data: session } = useSession() + const fullProject = projects.find((project) => project.id === fullViewProject(pathname)) const inSettings = Boolean(fullProject && settingsProject(pathname)) + const organizationId = projects.find((project) => project.organizationId)?.organizationId const toggleCollapsed = useSidebarStore((state) => state.toggleCollapsed) const scrollContainerRef = useRef(null) const scrollContentRef = useRef(null) @@ -94,6 +103,8 @@ export function ProtoSidebar() { contentRef: scrollContentRef, enabled: !isCollapsed, }) + const organizationName = organization?.name ?? 'Organization' + const userName = session?.user?.name ?? 'You' return (
    @@ -182,13 +193,13 @@ export function ProtoSidebar() {
    {fullProject && inSettings ? ( ) : fullProject ? (
    - {WORKSPACES.map((workspace) => ( + {projects.map((project) => ( - {GENERAL_CHATS.length > 0 && ( - -
    - {GENERAL_CHATS.map((chat) => ( - - ))} -
    -
    + /> )} )} @@ -250,9 +250,9 @@ export function ProtoSidebar() { } + leftAdornment={} > - {PEOPLE.teddy.name} + {userName}
    {!isCollapsed && } @@ -262,8 +262,38 @@ export function ProtoSidebar() { ) } +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 @@ -273,21 +303,21 @@ interface ProjectTreeProps { * A project row with its chats listed underneath, like Codex's project → threads. * Hovering the row reveals + for a new chat in that project. */ -function ProjectTree({ workspace, pathname, openChat, railCollapsed }: ProjectTreeProps) { +function ProjectTree({ project, pathname, openChat, railCollapsed }: ProjectTreeProps) { const [showAll, setShowAll] = useState(false) - const base = `${PROTO_BASE}/p/${workspace.id}` + const base = `${PROTO_BASE}/p/${project.id}` const inProject = pathname === base || Boolean(pathname?.startsWith(`${base}/`)) /** 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 { data: chats = [] } = useMothershipChats(project.id) 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 && ( + {project.mock.needsYou > 0 && ( - {workspace.needsYou} + {project.mock.needsYou} )} @@ -327,7 +357,7 @@ function ProjectTree({ workspace, pathname, openChat, railCollapsed }: ProjectTr )}
    - {!railCollapsed && expanded && ( + {!railCollapsed && expanded && chats.length > 0 && (
    {visible.map((chat) => ( - {running ? ( + {chat.isActive ? ( ) : ( - {chat.age} + + {compactAge(chat.updatedAt)} + )} ) @@ -398,20 +429,20 @@ 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) +function FullViewSections({ project, pathname, openChat, railCollapsed }: FullViewSectionsProps) { + const { data: chats = [] } = useMothershipChats(project.id) return ( <>
    - {fullNavItems(workspace.id, BUILD_SECTIONS).map((item) => ( + {fullNavItems(project.id, BUILD_SECTIONS).map((item) => ( @@ -429,7 +460,7 @@ function FullViewSections({ workspace, pathname, openChat, railCollapsed }: Full ))} @@ -441,14 +472,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) +/** + * Settings: the project sidebar gives way to the settings list, grouped like prod. + * The project's own General page stays here; every other section opens the real settings page. + */ +function SettingsSections({ project, pathname, railCollapsed }: SettingsSectionsProps) { + const base = protoRoutes.settings(project.id) return ( <> {SETTINGS_GROUPS.map((group, index) => ( @@ -460,7 +494,10 @@ 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 = + item.group === 'project' + ? protoRoutes.settings(project.id, item.id) + : workspaceRoutes.settings(project.id, item.id as WorkspaceSettingsSection) 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..404c7090a74 100644 --- a/apps/sim/app/playground/org/components/resource-section.tsx +++ b/apps/sim/app/playground/org/components/resource-section.tsx @@ -1,33 +1,79 @@ 'use client' -import { useState } from 'react' -import { Chip, ChipTag } from '@sim/emcn' +import { type ReactNode, useMemo, useState } from 'react' +import { ChipLink, 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 { useRouter } from 'next/navigation' +import { DASHBOARD_CONTENT_TYPE } from '@/lib/dashboards/file' +import type { Project } from '@/app/playground/org/lib/project' +import { workspaceRoutes } from '@/app/playground/org/lib/routes' +import { + type ProjectResources, + useProjectResources, +} from '@/app/playground/org/lib/use-project-resources' import { timeCell } from '@/app/workspace/[workspaceId]/components/resource/components/time-cell' import { Resource, type ResourceColumn, type ResourceRow, } from '@/app/workspace/[workspaceId]/components/resource/resource' +import { useWorkspaceCredentials } from '@/hooks/queries/credentials' +import { type LogFilters, useLogsList } from '@/hooks/queries/logs' -type ResourceSectionId = keyof typeof RESOURCES +export type ResourceSectionId = + | 'workflows' + | 'logs' + | 'tables' + | 'files' + | 'knowledge' + | 'credentials' -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, which opens the real list page where the action lives. */ + create?: string + createHref: (workspaceId: string) => string + meta: string +} + +const CONFIG: Record = { + workflows: { + title: 'Workflows', + icon: Workflow, + create: 'New workflow', + createHref: workspaceRoutes.workflows, + meta: 'Status', + }, + logs: { title: 'Logs', icon: Library, createHref: workspaceRoutes.logs, meta: 'Result' }, + tables: { + title: 'Tables', + icon: Table, + create: 'New table', + createHref: workspaceRoutes.tables, + meta: 'Rows', + }, + files: { + title: 'Files', + icon: Files, + create: 'Upload', + createHref: workspaceRoutes.files, + meta: 'Type', + }, + knowledge: { + title: 'Knowledge', + icon: Database, + create: 'New base', + createHref: workspaceRoutes.knowledge, + meta: 'Documents', + }, + credentials: { + title: 'Integrations', + icon: Integration, + create: 'Connect', + createHref: workspaceRoutes.integrations, + meta: 'Status', + }, } const COLUMNS: ResourceColumn[] = [ @@ -37,32 +83,170 @@ 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 }) { +/** Runs shown in the project's Logs section: newest first, every level. */ +const LOG_FILTERS: LogFilters = { + timeRange: 'All time', + level: 'all', + workflowIds: [], + folderIds: [], + triggers: [], + searchQuery: '', + limit: 50, + sortBy: 'date', + sortOrder: 'desc', +} + +interface SectionItem { + id: string + name: string + meta: string + /** Render the detail as a tag: an error, a failed run, an expired credential. */ + flagged?: boolean + owner: string + updated: string | Date | null | undefined + href: string +} + +function plural(count: number, noun: string): string { + return `${count.toLocaleString()} ${noun}${count === 1 ? '' : 's'}` +} + +function fileKind(type: string): string { + if (type === DASHBOARD_CONTENT_TYPE) return 'Dashboard' + if (type.startsWith('text/markdown')) return 'Markdown' + if (type.startsWith('image/')) return 'Image' + if (type === 'application/pdf') return 'PDF' + if (type === 'text/csv') return 'CSV' + return type.split('/').pop() ?? type +} + +function capitalize(value: string): string { + return value ? value[0].toUpperCase() + value.slice(1) : value +} + +function resourceItems( + section: Exclude, + workspaceId: string, + resources: ProjectResources +): SectionItem[] { + const owner = (userId: string | null | undefined) => + (userId && resources.memberNames.get(userId)) || '—' + switch (section) { + case 'workflows': + return resources.workflows.map((workflow) => ({ + id: workflow.id, + name: workflow.name, + meta: workflow.isDeployed ? 'Deployed' : 'Draft', + owner: (workflow.folderId && resources.folders[workflow.folderId]?.name) || '—', + updated: workflow.lastModified, + href: workspaceRoutes.workflow(workspaceId, workflow.id), + })) + case 'tables': + return resources.tables.map((table) => ({ + id: table.id, + name: table.name, + meta: plural(table.rowCount, 'row'), + owner: owner(table.createdBy), + updated: table.updatedAt, + href: workspaceRoutes.table(workspaceId, table.id), + })) + case 'knowledge': + return resources.knowledgeBases.map((base) => ({ + id: base.id, + name: base.name, + meta: plural(base.docCount ?? 0, 'doc'), + owner: owner(base.userId), + updated: base.updatedAt, + href: workspaceRoutes.knowledgeBase(workspaceId, base.id), + })) + case 'files': + return resources.files.map((file) => ({ + id: file.id, + name: file.name, + meta: fileKind(file.type), + owner: owner(file.uploadedBy), + updated: file.updatedAt, + href: workspaceRoutes.file(workspaceId, file.id), + })) + } +} + +interface ResourceSectionProps { + project: Project + section: ResourceSectionId +} + +/** The existing Resource list over the workspace's real rows; a row opens the real page. */ +export function ResourceSection({ project, section }: ResourceSectionProps) { const config = CONFIG[section] + const router = useRouter() const [search, setSearch] = useState('') + const resources = useProjectResources(project.id) + const logs = useLogsList(project.id, LOG_FILTERS, { enabled: section === 'logs' }) + const credentials = useWorkspaceCredentials({ + workspaceId: project.id, + type: 'oauth', + enabled: section === 'credentials', + }) const Icon = config.icon - const rows: ResourceRow[] = RESOURCES[section] + + const items = useMemo(() => { + if (section === 'logs') + return (logs.data?.pages.flatMap((page) => page.logs) ?? []).map((log) => { + const status = capitalize(log.status ?? log.level) + return { + id: log.id, + name: log.workflow?.name ?? log.jobTitle ?? 'Run', + meta: log.duration ? `${status} · ${log.duration}` : status, + flagged: /error|fail/i.test(`${log.status ?? ''} ${log.level}`), + owner: capitalize(log.trigger ?? '—'), + updated: log.createdAt, + href: workspaceRoutes.logs(project.id, log.executionId), + } + }) + if (section === 'credentials') + return (credentials.data ?? []).map((credential) => ({ + id: credential.id, + name: credential.displayName, + meta: credential.status ? capitalize(credential.status) : 'Connected', + flagged: /expired|error|invalid|revoked/i.test(credential.status ?? ''), + owner: resources.memberNames.get(credential.createdBy) ?? '—', + updated: credential.updatedAt, + href: workspaceRoutes.credential(project.id, credential.id), + })) + return resourceItems(section, project.id, resources) + }, [section, project.id, resources, logs.data, credentials.data]) + + const hrefById = useMemo(() => new Map(items.map((item) => [item.id, item.href])), [items]) + const rows: ResourceRow[] = items .filter((item) => item.name.toLowerCase().includes(search.toLowerCase())) .map((item) => ({ id: item.id, cells: { name: { icon: , label: item.name }, - meta: /expired|error|failing/i.test(item.meta) + meta: item.flagged ? { content: {item.meta} } : { label: item.meta }, owner: { label: item.owner }, updated: timeCell(item.updated), }, })) + + const pending = + section === 'logs' + ? logs.isPending + : section === 'credentials' + ? credentials.isPending + : resources.isPending + return ( + {config.create} - + ) : undefined } search={{ @@ -71,7 +255,24 @@ export function ResourceSection({ section }: { section: ResourceSectionId }) { placeholder: `Search ${config.title.toLowerCase()}`, }} /> - + {pending && rows.length === 0 ? ( + Loading {config.title.toLowerCase()}… + ) : rows.length === 0 ? ( + No {config.title.toLowerCase()} yet. + ) : ( + { + const href = hrefById.get(rowId) + if (href) router.push(href) + }} + /> + )} ) } + +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..e4faee16e8b 100644 --- a/apps/sim/app/playground/org/components/workspace-view.tsx +++ b/apps/sim/app/playground/org/components/workspace-view.tsx @@ -7,7 +7,7 @@ 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 { MAIN_SECTIONS, protoRoutes, @@ -15,9 +15,10 @@ import { type WorkspaceSection, } from '@/app/playground/org/lib/routes' import { DEFAULT_SETTINGS_SECTION } from '@/app/playground/org/lib/settings-nav' +import { useProjectResources } 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: boolean @@ -28,7 +29,7 @@ interface WorkspaceViewProps { * 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. */ -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 ( @@ -43,12 +44,12 @@ export function WorkspaceView({ workspace, section, full, settingsSection }: Wor

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

    -

    {workspace.description}

    +
    @@ -62,7 +63,7 @@ export function WorkspaceView({ workspace, section, full, settingsSection }: Wor {MAIN_SECTIONS.map((item) => ( @@ -73,38 +74,58 @@ export function WorkspaceView({ workspace, section, full, settingsSection }: Wor )}
    - +
    ) } +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}` +} + +/** The pack's purpose line when it was chosen by name; otherwise what the workspace holds. */ +function ProjectDescription({ project }: { project: Project }) { + const { workflows, tables, knowledgeBases, files, isPending } = useProjectResources(project.id) + const text = project.overlayMatched + ? project.mock.description + : isPending + ? '' + : [ + plural(workflows.length, 'workflow'), + plural(tables.length, 'table'), + plural(knowledgeBases.length, 'knowledge base'), + plural(files.length, 'file'), + ].join(' · ') + 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 'settings': return ( ) default: - return + return } } diff --git a/apps/sim/app/playground/org/layout.tsx b/apps/sim/app/playground/org/layout.tsx index ba62e526214..d3430c95e7e 100644 --- a/apps/sim/app/playground/org/layout.tsx +++ b/apps/sim/app/playground/org/layout.tsx @@ -1,10 +1,27 @@ 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/changelog-data.ts b/apps/sim/app/playground/org/lib/changelog-data.ts index a14ba3b0f34..b2af291f5ae 100644 --- a/apps/sim/app/playground/org/lib/changelog-data.ts +++ b/apps/sim/app/playground/org/lib/changelog-data.ts @@ -1,3 +1,5 @@ +import { CLAIMS_CHANGELOG, CLAIMS_DRAFT } from '@/app/playground/org/lib/claims-data' + export interface ChangelogMetric { label: string before: number @@ -77,6 +79,7 @@ export interface DraftRelease { } export const DRAFTS: DraftRelease[] = [ + CLAIMS_DRAFT, { workspaceId: 'infra', versions: [ @@ -277,6 +280,7 @@ export const DRAFTS: DraftRelease[] = [ ] export const CHANGELOG: ChangelogEntry[] = [ + ...CLAIMS_CHANGELOG, { id: 'kb-resync', workspaceId: 'infra', diff --git a/apps/sim/app/playground/org/lib/claims-data.ts b/apps/sim/app/playground/org/lib/claims-data.ts new file mode 100644 index 00000000000..98ecfbc5331 --- /dev/null +++ b/apps/sim/app/playground/org/lib/claims-data.ts @@ -0,0 +1,478 @@ +import type { ChangelogEntry, DraftRelease } from '@/app/playground/org/lib/changelog-data' +import type { + Chat, + Issue, + PlanStep, + Release, + Report, + TriageProposal, +} from '@/app/playground/org/lib/mock-data' + +/** + * 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: 14, + }, + 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/lib/mock-data.ts b/apps/sim/app/playground/org/lib/mock-data.ts index a7ec8d2721a..14d6bd96e63 100644 --- a/apps/sim/app/playground/org/lib/mock-data.ts +++ b/apps/sim/app/playground/org/lib/mock-data.ts @@ -1,3 +1,9 @@ +import { + CLAIMS_CHATS, + CLAIMS_ISSUES, + CLAIMS_RELEASES, + CLAIMS_TRIAGE, +} from '@/app/playground/org/lib/claims-data' import { INFRA_ISSUES, INFRA_RELEASES, @@ -92,10 +98,15 @@ export interface Release { 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 + match?: RegExp tracker: { kind: TrackerKind; label: string; synced?: string } feedbackSources: { source: FeedbackSource; label: string }[] dashboards: string[] @@ -121,10 +132,22 @@ 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, + 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 +157,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 +171,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 +181,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 +190,7 @@ export const WORKSPACES: Workspace[] = [ ] export const CHATS: Chat[] = [ + ...CLAIMS_CHATS, { id: 'c0', age: '5m', @@ -295,7 +322,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 +391,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,7 +714,12 @@ const OTHER_ISSUES: Issue[] = [ }, ] -export const ISSUES: Issue[] = [...INFRA_ISSUES, ...SUPPORT_ISSUES, ...OTHER_ISSUES] +export const ISSUES: Issue[] = [ + ...CLAIMS_ISSUES, + ...INFRA_ISSUES, + ...SUPPORT_ISSUES, + ...OTHER_ISSUES, +] export const STATUS_ORDER: IssueStatus[] = [ 'in_progress', @@ -871,7 +903,12 @@ export function issueByKey(key: string): Issue | undefined { return ISSUES.find((issue) => issue.key === key) } -const TRACKER_PREFIX: Record = { infra: 'BOT', support: 'SUPT', growth: 'GRW' } +const TRACKER_PREFIX: Record = { + infra: 'BOT', + support: 'SUPT', + growth: 'GRW', + claims: 'CLM', +} const TRACKER_STATUS: Record = { backlog: 'Backlog', 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..f6e6a25045d --- /dev/null +++ b/apps/sim/app/playground/org/lib/project.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react' +import { WORKSPACES, type Workspace } from '@/app/playground/org/lib/mock-data' +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 name, so its description reads as this project's. */ + overlayMatched: boolean +} + +/** + * Picks the overlay pack for a real workspace: the first pack whose `match` hits the workspace + * name, else round-robin by sidebar position so neighbouring projects get different packs. + */ +export function matchOverlay( + name: string, + index: number +): Pick { + const matched = WORKSPACES.find((pack) => pack.match?.test(name)) + return matched + ? { mock: matched, overlayMatched: true } + : { mock: WORKSPACES[index % WORKSPACES.length], overlayMatched: false } +} + +export function useProjects() { + const query = useWorkspacesQuery() + const projects = useMemo( + () => + (query.data ?? []).map((workspace, index) => ({ + id: workspace.id, + name: workspace.name, + organizationId: workspace.organizationId ?? null, + ...matchOverlay(workspace.name, index), + })), + [query.data] + ) + return { projects, isPending: query.isPending, error: query.error } +} + +export function useProject(workspaceId: string) { + const { projects, isPending, error } = useProjects() + return { project: projects.find((project) => project.id === workspaceId), isPending, error } +} diff --git a/apps/sim/app/playground/org/lib/routes.ts b/apps/sim/app/playground/org/lib/routes.ts index 9ae4e2c5cb8..f3447565303 100644 --- a/apps/sim/app/playground/org/lib/routes.ts +++ b/apps/sim/app/playground/org/lib/routes.ts @@ -10,7 +10,10 @@ import { Table, Workflow, } from '@sim/emcn/icons' -import { CHATS } from '@/app/playground/org/lib/mock-data' +import { + getWorkspaceSettingsHref, + type WorkspaceSettingsSection, +} from '@/components/settings/navigation' import { DEFAULT_SETTINGS_SECTION, SETTINGS_NAV } from '@/app/playground/org/lib/settings-nav' export const PROTO_BASE = '/playground/org' @@ -43,13 +46,10 @@ 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) @@ -114,3 +114,25 @@ 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), +} diff --git a/apps/sim/app/playground/org/lib/search-params.ts b/apps/sim/app/playground/org/lib/search-params.ts index 388439871e3..e64be257c67 100644 --- a/apps/sim/app/playground/org/lib/search-params.ts +++ b/apps/sim/app/playground/org/lib/search-params.ts @@ -1,13 +1,12 @@ -import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { parseAsString } from 'nuqs/server' 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(''), } 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..a173e80d22d --- /dev/null +++ b/apps/sim/app/playground/org/lib/use-project-resources.ts @@ -0,0 +1,47 @@ +import { useMemo } from 'react' +import { DASHBOARD_CONTENT_TYPE } from '@/lib/dashboards/file' +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) { + const workflows = useWorkflows(workspaceId) + const folders = useFolderMap(workspaceId) + const tables = useTablesList(workspaceId) + const knowledgeBases = useKnowledgeBasesQuery(workspaceId, { includeCounts: true }) + const files = useWorkspaceFiles(workspaceId) + const members = useWorkspaceMembersQuery(workspaceId) + + 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: + workflows.isPending || tables.isPending || knowledgeBases.isPending || files.isPending, + filesPending: files.isPending, + } +} + +export type ProjectResources = ReturnType 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..a8c270bb8d2 --- /dev/null +++ b/apps/sim/app/playground/org/p/[workspaceId]/layout.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react' +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 + return ( + + {children} + + ) +} 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' From cb12e317addaee2e1e60adaddb01dbcd887c1cd3 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 17:31:29 -0700 Subject: [PATCH 02/15] fix(playground): pick overlay packs by workflow names, thread project into next release A pack now matches on the workspace's workflow names before its name, so the demo project picks up its pack whatever the workspace is called. The next-release card received the project id it was already using. Co-Authored-By: Claude Fable 5.1 --- .../playground/org/components/changelog.tsx | 8 ++-- apps/sim/app/playground/org/lib/mock-data.ts | 8 ++++ apps/sim/app/playground/org/lib/project.ts | 42 ++++++++++++------- 3 files changed, 40 insertions(+), 18 deletions(-) diff --git a/apps/sim/app/playground/org/components/changelog.tsx b/apps/sim/app/playground/org/components/changelog.tsx index 59e6cfda136..88387f217fd 100644 --- a/apps/sim/app/playground/org/components/changelog.tsx +++ b/apps/sim/app/playground/org/components/changelog.tsx @@ -75,6 +75,7 @@ export function Changelog({ project }: { project: Project }) { {draft && ( release(draft)} /> @@ -94,11 +95,12 @@ export function Changelog({ project }: { project: Project }) { interface NextReleaseProps { draft: DraftRelease + projectId: string released: boolean onRelease: () => void } -function NextRelease({ draft, released, onRelease }: NextReleaseProps) { +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,7 +135,7 @@ function NextRelease({ draft, released, onRelease }: NextReleaseProps) { ) : (
      {changes.map((change) => ( - + ))}
    )} @@ -145,7 +147,7 @@ function NextRelease({ draft, released, onRelease }: NextReleaseProps) { {draft.running.map((work) => (
  • diff --git a/apps/sim/app/playground/org/lib/mock-data.ts b/apps/sim/app/playground/org/lib/mock-data.ts index 14d6bd96e63..c577b899617 100644 --- a/apps/sim/app/playground/org/lib/mock-data.ts +++ b/apps/sim/app/playground/org/lib/mock-data.ts @@ -106,7 +106,10 @@ 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[] @@ -138,6 +141,11 @@ export const WORKSPACES: Workspace[] = [ 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: [], diff --git a/apps/sim/app/playground/org/lib/project.ts b/apps/sim/app/playground/org/lib/project.ts index f6e6a25045d..abbf4f47e50 100644 --- a/apps/sim/app/playground/org/lib/project.ts +++ b/apps/sim/app/playground/org/lib/project.ts @@ -1,5 +1,6 @@ -import { useMemo } from 'react' +import { useQueries } from '@tanstack/react-query' import { WORKSPACES, type Workspace } from '@/app/playground/org/lib/mock-data' +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. */ @@ -10,19 +11,24 @@ export interface Project { 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 name, so its description reads as this project's. */ + /** True when the pack was chosen by what the workspace holds or is called, not by position. */ overlayMatched: boolean } /** - * Picks the overlay pack for a real workspace: the first pack whose `match` hits the workspace - * name, else round-robin by sidebar position so neighbouring projects get different packs. + * 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, else + * round-robin by sidebar position so neighbouring projects get different packs. */ export function matchOverlay( name: string, + workflowNames: readonly string[], index: number ): Pick { - const matched = WORKSPACES.find((pack) => pack.match?.test(name)) + 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: WORKSPACES[index % WORKSPACES.length], overlayMatched: false } @@ -30,16 +36,22 @@ export function matchOverlay( export function useProjects() { const query = useWorkspacesQuery() - const projects = useMemo( - () => - (query.data ?? []).map((workspace, index) => ({ - id: workspace.id, - name: workspace.name, - organizationId: workspace.organizationId ?? null, - ...matchOverlay(workspace.name, index), - })), - [query.data] - ) + 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 projects: Project[] = workspaces.map((workspace, index) => ({ + id: workspace.id, + name: workspace.name, + organizationId: workspace.organizationId ?? null, + ...matchOverlay( + workspace.name, + (workflowLists[index]?.data ?? []).map((workflow) => workflow.name), + index + ), + })) return { projects, isPending: query.isPending, error: query.error } } From ad5a2552fbc050a96ada8bd1f72a8ff3e8b53008 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 17:54:02 -0700 Subject: [PATCH 03/15] fix(playground): defer the handed-off first message, share the project description The message handed from the home composer is sent one tick after the side chat mounts, with cleanup, so a simulated remount no longer withdraws it. The settings General page uses the same description line as the project header. Co-Authored-By: Claude Fable 5.1 --- .../playground/org/components/live-chat.tsx | 14 +++++++++++--- .../org/components/project-settings.tsx | 4 +++- .../org/components/workspace-view.tsx | 19 ++----------------- .../org/lib/use-project-resources.ts | 18 ++++++++++++++++++ 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/apps/sim/app/playground/org/components/live-chat.tsx b/apps/sim/app/playground/org/components/live-chat.tsx index 69a15929707..96be5b798e4 100644 --- a/apps/sim/app/playground/org/components/live-chat.tsx +++ b/apps/sim/app/playground/org/components/live-chat.tsx @@ -57,9 +57,17 @@ export function LiveChat({ const sentInitialRef = useRef(false) useEffect(() => { if (!initialMessage || chatId || sentInitialRef.current) return - sentInitialRef.current = true - void sendMessage(initialMessage) - onInitialMessageSent?.() + /** + * 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) diff --git a/apps/sim/app/playground/org/components/project-settings.tsx b/apps/sim/app/playground/org/components/project-settings.tsx index 4886775eeae..9d70be0a6c6 100644 --- a/apps/sim/app/playground/org/components/project-settings.tsx +++ b/apps/sim/app/playground/org/components/project-settings.tsx @@ -8,6 +8,7 @@ import type { WorkspaceSettingsSection } from '@/components/settings/navigation' import type { Project } from '@/app/playground/org/lib/project' import { workspaceRoutes } from '@/app/playground/org/lib/routes' import { SETTINGS_NAV } from '@/app/playground/org/lib/settings-nav' +import { useProjectDescription } from '@/app/playground/org/lib/use-project-resources' interface ProjectSettingsProps { project: Project @@ -51,11 +52,12 @@ export function ProjectSettings({ project, sectionId }: ProjectSettingsProps) { function ProjectGeneral({ project }: { project: Project }) { const { tracker, feedbackSources } = project.mock + const description = useProjectDescription(project) const TrackerIcon = tracker.kind === 'jira' ? JiraIcon : tracker.kind === 'linear' ? LinearIcon : null const rows: [string, ReactNode][] = [ ['Name', project.name], - ['Description', project.mock.description], + ['Description', description], [ 'Tracker', diff --git a/apps/sim/app/playground/org/components/workspace-view.tsx b/apps/sim/app/playground/org/components/workspace-view.tsx index e4faee16e8b..854166095fc 100644 --- a/apps/sim/app/playground/org/components/workspace-view.tsx +++ b/apps/sim/app/playground/org/components/workspace-view.tsx @@ -15,7 +15,7 @@ import { type WorkspaceSection, } from '@/app/playground/org/lib/routes' import { DEFAULT_SETTINGS_SECTION } from '@/app/playground/org/lib/settings-nav' -import { useProjectResources } from '@/app/playground/org/lib/use-project-resources' +import { useProjectDescription } from '@/app/playground/org/lib/use-project-resources' interface WorkspaceViewProps { project: Project @@ -80,23 +80,8 @@ export function WorkspaceView({ project, section, full, settingsSection }: Works ) } -function plural(count: number, noun: string): string { - return `${count} ${noun}${count === 1 ? '' : 's'}` -} - -/** The pack's purpose line when it was chosen by name; otherwise what the workspace holds. */ function ProjectDescription({ project }: { project: Project }) { - const { workflows, tables, knowledgeBases, files, isPending } = useProjectResources(project.id) - const text = project.overlayMatched - ? project.mock.description - : isPending - ? '' - : [ - plural(workflows.length, 'workflow'), - plural(tables.length, 'table'), - plural(knowledgeBases.length, 'knowledge base'), - plural(files.length, 'file'), - ].join(' · ') + const text = useProjectDescription(project) return

    {text}

    } diff --git a/apps/sim/app/playground/org/lib/use-project-resources.ts b/apps/sim/app/playground/org/lib/use-project-resources.ts index a173e80d22d..1608a6ba796 100644 --- a/apps/sim/app/playground/org/lib/use-project-resources.ts +++ b/apps/sim/app/playground/org/lib/use-project-resources.ts @@ -1,5 +1,6 @@ import { useMemo } from 'react' import { DASHBOARD_CONTENT_TYPE } from '@/lib/dashboards/file' +import type { Project } from '@/app/playground/org/lib/project' import { useFolderMap } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useTablesList } from '@/hooks/queries/tables' @@ -45,3 +46,20 @@ export function useProjectResources(workspaceId: string) { } export type ProjectResources = ReturnType + +function plural(count: number, noun: string): string { + return `${count} ${noun}${count === 1 ? '' : 's'}` +} + +/** The pack's purpose line when the pack was chosen for this project; otherwise what the workspace holds. */ +export function useProjectDescription(project: Project): string { + const { workflows, tables, knowledgeBases, files, isPending } = useProjectResources(project.id) + if (project.overlayMatched) return project.mock.description + if (isPending) return '' + return [ + plural(workflows.length, 'workflow'), + plural(tables.length, 'table'), + plural(knowledgeBases.length, 'knowledge base'), + plural(files.length, 'file'), + ].join(' · ') +} From c76aa17aea388dd68015adc1b5a9b56690c76837 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 18:05:14 -0700 Subject: [PATCH 04/15] fix(playground): wait for the overlay pack before 404ing an issue The pack is chosen from the workspace's workflow names, which load after the workspace list, so an issue route now shows a loading state until the list arrives instead of calling notFound on the fallback pack. The needs-you badge waits the same way, and the draft chart's release index points at its last value. Co-Authored-By: Claude Fable 5.1 --- apps/sim/app/playground/org/components/proto-page.tsx | 5 ++++- apps/sim/app/playground/org/components/proto-sidebar.tsx | 2 +- apps/sim/app/playground/org/lib/claims-data.ts | 2 +- apps/sim/app/playground/org/lib/project.ts | 3 +++ 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/playground/org/components/proto-page.tsx b/apps/sim/app/playground/org/components/proto-page.tsx index 33577f4d571..4e840e4f957 100644 --- a/apps/sim/app/playground/org/components/proto-page.tsx +++ b/apps/sim/app/playground/org/components/proto-page.tsx @@ -46,7 +46,10 @@ export function ProtoPage({ slug }: { slug?: string[] }) { {(project) => { const issue = issueByKey(route.issueKey) - if (!issue || issue.workspaceId !== project.mock.id) notFound() + if (!issue || issue.workspaceId !== project.mock.id) { + if (project.overlayPending) return + notFound() + } return ( diff --git a/apps/sim/app/playground/org/components/proto-sidebar.tsx b/apps/sim/app/playground/org/components/proto-sidebar.tsx index b95af167ecc..0f499432f1b 100644 --- a/apps/sim/app/playground/org/components/proto-sidebar.tsx +++ b/apps/sim/app/playground/org/components/proto-sidebar.tsx @@ -340,7 +340,7 @@ function ProjectTree({ project, pathname, openChat, railCollapsed }: ProjectTree {!railCollapsed && ( <> - {project.mock.needsYou > 0 && ( + {!project.overlayPending && project.mock.needsYou > 0 && ( {project.mock.needsYou} diff --git a/apps/sim/app/playground/org/lib/claims-data.ts b/apps/sim/app/playground/org/lib/claims-data.ts index 98ecfbc5331..1fd2b1a0599 100644 --- a/apps/sim/app/playground/org/lib/claims-data.ts +++ b/apps/sim/app/playground/org/lib/claims-data.ts @@ -391,7 +391,7 @@ export const CLAIMS_DRAFT: DraftRelease = { title: 'Cases sent back by analysts', kind: 'bar', values: [9, 8, 11, 7, 10, 9, 8, 6, 5, 6, 4, 3, 3, 2], - releaseIndex: 14, + releaseIndex: 13, }, marker: 'v3', release: { diff --git a/apps/sim/app/playground/org/lib/project.ts b/apps/sim/app/playground/org/lib/project.ts index abbf4f47e50..5ced639efb7 100644 --- a/apps/sim/app/playground/org/lib/project.ts +++ b/apps/sim/app/playground/org/lib/project.ts @@ -13,6 +13,8 @@ export interface Project { 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 } /** @@ -51,6 +53,7 @@ export function useProjects() { (workflowLists[index]?.data ?? []).map((workflow) => workflow.name), index ), + overlayPending: workflowLists[index]?.isPending ?? true, })) return { projects, isPending: query.isPending, error: query.error } } From 8db9094c2769e943750d70dc30052804db51b3d7 Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 18:15:24 -0700 Subject: [PATCH 05/15] feat(playground): mock projects beside real ones, Connectors as the org integrations page Packs no real workspace claims stay in the sidebar as mock projects with their prototype chats, rows and sample dashboards; real hooks stay off for them. A real workspace nothing claims shows only its real resources. Connectors renders the organization integrations component the way the org route does. Co-Authored-By: Claude Fable 5.1 --- .../playground/org/components/issue-page.tsx | 4 +- .../org/components/new-chat-home.tsx | 4 +- .../org/components/project-settings.tsx | 2 + .../org/components/project-shell.tsx | 7 ++- .../org/components/proto-dashboard.tsx | 37 ++++++----- .../org/components/proto-sidebar.tsx | 51 +++++++++++++--- .../org/components/resource-section.tsx | 42 +++++++++---- .../app/playground/org/connectors/page.tsx | 52 ++++++++++++++++ apps/sim/app/playground/org/lib/project.ts | 61 ++++++++++++++++--- .../org/lib/use-project-resources.ts | 28 ++++++--- .../playground/org/p/[workspaceId]/layout.tsx | 2 + 11 files changed, 234 insertions(+), 56 deletions(-) create mode 100644 apps/sim/app/playground/org/connectors/page.tsx diff --git a/apps/sim/app/playground/org/components/issue-page.tsx b/apps/sim/app/playground/org/components/issue-page.tsx index d1b8036e8a9..f7880b54762 100644 --- a/apps/sim/app/playground/org/components/issue-page.tsx +++ b/apps/sim/app/playground/org/components/issue-page.tsx @@ -40,7 +40,7 @@ import { STATUS_ORDER, ticketUrl, } from '@/app/playground/org/lib/mock-data' -import type { Project } from '@/app/playground/org/lib/project' +import { type Project, realWorkspaceId } from '@/app/playground/org/lib/project' import { protoRoutes, workspaceRoutes } from '@/app/playground/org/lib/routes' import { useProjectResources } from '@/app/playground/org/lib/use-project-resources' @@ -59,7 +59,7 @@ interface IssuePageProps { /** Full-page issue, Linear-style: content column plus a properties column. */ export function IssuePage({ project, issue }: IssuePageProps) { - const resolveHref = useResourceHrefs(project.id) + const resolveHref = useResourceHrefs(realWorkspaceId(project)) const [status, setStatus] = useState(issue.status) const [priority, setPriority] = useState(issue.priority) const [activity, setActivity] = useState(issue.activity) 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 ed915a6ff3c..6cbf2cd06b8 100644 --- a/apps/sim/app/playground/org/components/new-chat-home.tsx +++ b/apps/sim/app/playground/org/components/new-chat-home.tsx @@ -72,7 +72,9 @@ export function NewChatHome() { onSubmit={(text) => { const message = text.trim() if (!message) return - if (selected) { + if (selected?.isMock) { + router.push(`${protoRoutes.workspace(selected.id)}?chat=new`) + } else if (selected) { router.push( `${protoRoutes.workspace(selected.id)}?chat=new&q=${encodeURIComponent(message)}` ) diff --git a/apps/sim/app/playground/org/components/project-settings.tsx b/apps/sim/app/playground/org/components/project-settings.tsx index 9d70be0a6c6..b63cbfa6fee 100644 --- a/apps/sim/app/playground/org/components/project-settings.tsx +++ b/apps/sim/app/playground/org/components/project-settings.tsx @@ -35,6 +35,8 @@ export function ProjectSettings({ project, sectionId }: ProjectSettingsProps) { {active.group === 'project' ? ( + ) : project.isMock ? ( + Nothing here yet. ) : ( chat.id === chatId) /** Chats from the overlay pack (changelog and issue stories) keep their canned threads. */ const mockChat: Chat | undefined = CHATS.find( @@ -119,6 +120,10 @@ function SideChat({ ) : mockChat ? ( + ) : project.isMock ? ( +
    + +
    ) : ( Loading…

    - const options: DashboardOption[] = [ - ...dashboardFiles.map((file) => ({ id: file.id, title: file.name, kind: 'file' as const })), - { id: RUNS_ID, title: 'Runs', kind: 'runs' }, - ...(dashboardFiles.length - ? [] - : project.mock.dashboards.map((id) => ({ - id, - title: `${MOCK_DASHBOARDS[id]?.title ?? id} (sample)`, - kind: 'sample' as const, - }))), - ] + 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 project’s tables. +

    + ) const current = options.find((option) => option.id === selected) ?? options[0] const title = ( @@ -93,7 +102,7 @@ interface DashboardBodyProps { } function DashboardBody({ project, option, title }: DashboardBodyProps) { - const { dashboardFiles } = useProjectResources(project.id) + const { dashboardFiles } = useProjectResources(realWorkspaceId(project)) if (option.kind === 'runs') return if (option.kind === 'file') { const file = dashboardFiles.find((candidate) => candidate.id === option.id) diff --git a/apps/sim/app/playground/org/components/proto-sidebar.tsx b/apps/sim/app/playground/org/components/proto-sidebar.tsx index 0f499432f1b..cf9f309f19b 100644 --- a/apps/sim/app/playground/org/components/proto-sidebar.tsx +++ b/apps/sim/app/playground/org/components/proto-sidebar.tsx @@ -31,6 +31,8 @@ import { IdentityTile } from '@/components/identity-tile/identity-tile' import type { WorkspaceSettingsSection } from '@/components/settings/navigation' import { useActiveOrganization, useSession } from '@/lib/auth/auth-client' import { RunningDot } from '@/app/playground/org/components/glyphs' +import { DRAFTS } from '@/app/playground/org/lib/changelog-data' +import { CHATS } from '@/app/playground/org/lib/mock-data' import { type Project, useProjects } from '@/app/playground/org/lib/project' import { fullViewProject, @@ -84,6 +86,34 @@ function compactAge(date: Date): string { 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 +} + +const RUNNING_MOCK_CHAT_IDS = new Set( + DRAFTS.flatMap((draft) => draft.running.map((w) => w.chat.id)) +) + +function toChatItems(project: Project, real: MothershipChatMetadata[] | undefined): ChatItem[] { + if (project.isMock) + return CHATS.filter((chat) => chat.workspaceId === project.mock.id).map((chat) => ({ + id: chat.id, + name: chat.title, + age: chat.age, + running: RUNNING_MOCK_CHAT_IDS.has(chat.id), + })) + 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() @@ -282,7 +312,12 @@ function OrgChats({ organizationId, pathname, railCollapsed }: OrgChatsProps) { {chats.map((chat) => ( @@ -310,7 +345,8 @@ function ProjectTree({ project, pathname, openChat, railCollapsed }: ProjectTree /** 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 { data: chats = [] } = useMothershipChats(project.id) + const { data: realChats } = useMothershipChats(project.isMock ? undefined : project.id) + const chats = toChatItems(project, realChats) const visible = showAll ? chats : chats.slice(0, CHAT_PREVIEW) const panelHref = (chatId: string) => `${inProject && pathname ? pathname : protoRoutes.workspace(project.id)}?chat=${chatId}` @@ -386,7 +422,7 @@ function ProjectTree({ project, pathname, openChat, railCollapsed }: ProjectTree } interface ChatRowProps { - chat: MothershipChatMetadata + chat: ChatItem href: string active: boolean } @@ -400,12 +436,10 @@ function ChatRow({ chat, href, active }: ChatRowProps) { className='sidebar-collapse-hide flex-1 text-[var(--text-body)] text-small' focusTarget='nearest-interactive' /> - {chat.isActive ? ( + {chat.running ? ( ) : ( - - {compactAge(chat.updatedAt)} - + {chat.age} )} ) @@ -437,7 +471,8 @@ interface FullViewSectionsProps { /** Full view: the project's build sections, then its chats, in place of Projects and Recent chats. */ function FullViewSections({ project, pathname, openChat, railCollapsed }: FullViewSectionsProps) { - const { data: chats = [] } = useMothershipChats(project.id) + const { data: realChats } = useMothershipChats(project.isMock ? undefined : project.id) + const chats = toChatItems(project, realChats) return ( <> diff --git a/apps/sim/app/playground/org/components/resource-section.tsx b/apps/sim/app/playground/org/components/resource-section.tsx index 404c7090a74..0e35f47cff7 100644 --- a/apps/sim/app/playground/org/components/resource-section.tsx +++ b/apps/sim/app/playground/org/components/resource-section.tsx @@ -1,11 +1,12 @@ 'use client' import { type ReactNode, useMemo, useState } from 'react' -import { ChipLink, ChipTag } from '@sim/emcn' +import { Chip, ChipLink, ChipTag } from '@sim/emcn' import { Database, Files, Integration, Library, Plus, Table, Workflow } from '@sim/emcn/icons' import { useRouter } from 'next/navigation' import { DASHBOARD_CONTENT_TYPE } from '@/lib/dashboards/file' -import type { Project } from '@/app/playground/org/lib/project' +import { RESOURCES } from '@/app/playground/org/lib/mock-data' +import { type Project, realWorkspaceId } from '@/app/playground/org/lib/project' import { workspaceRoutes } from '@/app/playground/org/lib/routes' import { type ProjectResources, @@ -181,16 +182,30 @@ export function ResourceSection({ project, section }: ResourceSectionProps) { const config = CONFIG[section] const router = useRouter() const [search, setSearch] = useState('') - const resources = useProjectResources(project.id) - const logs = useLogsList(project.id, LOG_FILTERS, { enabled: section === 'logs' }) + const workspaceId = realWorkspaceId(project) + const resources = useProjectResources(workspaceId) + const logs = useLogsList(workspaceId || undefined, LOG_FILTERS, { + enabled: section === 'logs' && Boolean(workspaceId), + }) const credentials = useWorkspaceCredentials({ - workspaceId: project.id, + workspaceId: workspaceId || undefined, type: 'oauth', - enabled: section === 'credentials', + enabled: section === 'credentials' && Boolean(workspaceId), }) const Icon = config.icon const items = useMemo(() => { + /** A mock project keeps the prototype's static rows; they open nothing. */ + if (project.isMock) + return RESOURCES[section].map((item) => ({ + id: item.id, + name: item.name, + meta: item.meta, + flagged: /expired|error|failing/i.test(item.meta), + owner: item.owner, + updated: item.updated, + href: '', + })) if (section === 'logs') return (logs.data?.pages.flatMap((page) => page.logs) ?? []).map((log) => { const status = capitalize(log.status ?? log.level) @@ -215,7 +230,7 @@ export function ResourceSection({ project, section }: ResourceSectionProps) { href: workspaceRoutes.credential(project.id, credential.id), })) return resourceItems(section, project.id, resources) - }, [section, project.id, resources, logs.data, credentials.data]) + }, [section, project.id, project.isMock, resources, logs.data, credentials.data]) const hrefById = useMemo(() => new Map(items.map((item) => [item.id, item.href])), [items]) const rows: ResourceRow[] = items @@ -232,8 +247,9 @@ export function ResourceSection({ project, section }: ResourceSectionProps) { }, })) - const pending = - section === 'logs' + const pending = project.isMock + ? false + : section === 'logs' ? logs.isPending : section === 'credentials' ? credentials.isPending @@ -243,11 +259,15 @@ export function ResourceSection({ project, section }: ResourceSectionProps) { + {config.create} + + ) : ( {config.create} - ) : undefined + ) } search={{ value: search, 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/project.ts b/apps/sim/app/playground/org/lib/project.ts index 5ced639efb7..7e2bac23dd6 100644 --- a/apps/sim/app/playground/org/lib/project.ts +++ b/apps/sim/app/playground/org/lib/project.ts @@ -15,17 +15,44 @@ export interface Project { 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 +} + +/** What a real workspace shows when no pack claims it: real resources, nothing mock on top. */ +const NO_PACK: Workspace = { + id: 'none', + name: '', + description: '', + tracker: { kind: 'sim', label: 'Sim tracker' }, + feedbackSources: [], + dashboards: [], + needsYou: 0, +} + +/** 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, + } } /** * 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, else - * round-robin by sidebar position so neighbouring projects get different packs. + * 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[], - index: number + workflowNames: readonly string[] ): Pick { const byWorkflow = WORKSPACES.find((pack) => pack.matchWorkflows?.some((wanted) => workflowNames.includes(wanted)) @@ -33,7 +60,7 @@ export function matchOverlay( const matched = byWorkflow ?? WORKSPACES.find((pack) => pack.match?.test(name)) return matched ? { mock: matched, overlayMatched: true } - : { mock: WORKSPACES[index % WORKSPACES.length], overlayMatched: false } + : { mock: NO_PACK, overlayMatched: false } } export function useProjects() { @@ -44,21 +71,37 @@ export function useProjects() { 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 projects: Project[] = workspaces.map((workspace, index) => ({ + const real: Project[] = workspaces.map((workspace, index) => ({ id: workspace.id, name: workspace.name, organizationId: workspace.organizationId ?? null, ...matchOverlay( workspace.name, - (workflowLists[index]?.data ?? []).map((workflow) => workflow.name), - index + (workflowLists[index]?.data ?? []).map((workflow) => workflow.name) ), overlayPending: workflowLists[index]?.isPending ?? true, + isMock: false, })) + /** 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] return { projects, isPending: query.isPending, error: query.error } } export function useProject(workspaceId: string) { const { projects, isPending, error } = useProjects() - return { project: projects.find((project) => project.id === workspaceId), isPending, error } + 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/use-project-resources.ts b/apps/sim/app/playground/org/lib/use-project-resources.ts index 1608a6ba796..5ff9db86adc 100644 --- a/apps/sim/app/playground/org/lib/use-project-resources.ts +++ b/apps/sim/app/playground/org/lib/use-project-resources.ts @@ -1,6 +1,6 @@ import { useMemo } from 'react' import { DASHBOARD_CONTENT_TYPE } from '@/lib/dashboards/file' -import type { Project } from '@/app/playground/org/lib/project' +import { type Project, realWorkspaceId } from '@/app/playground/org/lib/project' import { useFolderMap } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useTablesList } from '@/hooks/queries/tables' @@ -10,12 +10,17 @@ 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) { - const workflows = useWorkflows(workspaceId) - const folders = useFolderMap(workspaceId) - const tables = useTablesList(workspaceId) - const knowledgeBases = useKnowledgeBasesQuery(workspaceId, { includeCounts: true }) - const files = useWorkspaceFiles(workspaceId) - const members = useWorkspaceMembersQuery(workspaceId) + /** 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() @@ -40,8 +45,9 @@ export function useProjectResources(workspaceId: string) { dashboardFiles: fileLists.dashboardFiles, memberNames, isPending: - workflows.isPending || tables.isPending || knowledgeBases.isPending || files.isPending, - filesPending: files.isPending, + enabled && + (workflows.isPending || tables.isPending || knowledgeBases.isPending || files.isPending), + filesPending: enabled && files.isPending, } } @@ -53,7 +59,9 @@ function plural(count: number, noun: string): string { /** The pack's purpose line when the pack was chosen for this project; otherwise what the workspace holds. */ export function useProjectDescription(project: Project): string { - const { workflows, tables, knowledgeBases, files, isPending } = useProjectResources(project.id) + const { workflows, tables, knowledgeBases, files, isPending } = useProjectResources( + realWorkspaceId(project) + ) if (project.overlayMatched) return project.mock.description if (isPending) return '' return [ diff --git a/apps/sim/app/playground/org/p/[workspaceId]/layout.tsx b/apps/sim/app/playground/org/p/[workspaceId]/layout.tsx index a8c270bb8d2..affbba43016 100644 --- a/apps/sim/app/playground/org/p/[workspaceId]/layout.tsx +++ b/apps/sim/app/playground/org/p/[workspaceId]/layout.tsx @@ -1,4 +1,5 @@ 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' /** @@ -13,6 +14,7 @@ export default async function ProjectLayout({ params: Promise<{ workspaceId: string }> }) { const { workspaceId } = await params + if (MOCK_PROJECT_IDS.has(workspaceId)) return children return ( {children} From 224d287d7608d9e10a6e9531e266c7d602f1d7ec Mon Sep 17 00:00:00 2001 From: Marcus Chandra Date: Tue, 29 Sep 2026 18:23:23 -0700 Subject: [PATCH 06/15] feat(playground): forked workspaces are one project with environments A fork lineage is one project: the sidebar lists its root, the project header shows the current environment (Prod, Staging, Sandbox, from a trailing word on the workspace name or the fork depth) beside "Open full view", and a dropdown switches to another environment on the same section. Connectors resolves the organization the way the app entry does. Co-Authored-By: Claude Fable 5.1 --- .../org/components/environment-switcher.tsx | 58 +++++++++ .../org/components/new-chat-home.tsx | 2 +- .../org/components/proto-sidebar.tsx | 23 +++- .../org/components/workspace-view.tsx | 21 ++-- apps/sim/app/playground/org/lib/project.ts | 112 ++++++++++++++++-- 5 files changed, 189 insertions(+), 27 deletions(-) create mode 100644 apps/sim/app/playground/org/components/environment-switcher.tsx 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..c4cb398efac --- /dev/null +++ b/apps/sim/app/playground/org/components/environment-switcher.tsx @@ -0,0 +1,58 @@ +'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 + /** Whether the current page is the full view, so the switch lands on the same view. */ + full: boolean +} + +/** + * 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, full }: EnvironmentSwitcherProps) { + const router = useRouter() + const target = (workspaceId: string) => + full ? protoRoutes.full(workspaceId, section) : protoRoutes.workspace(workspaceId, section) + if (project.environments.length <= 1) + return ( + + {project.environment} + + ) + return ( + + + + {project.environment} + + + + {project.environments.map((environment) => ( + router.push(target(environment.workspaceId))} + > + {environment.label} + {environment.workspaceId === project.id && ( + + )} + + ))} + + + ) +} 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 6cbf2cd06b8..acc45fce7f9 100644 --- a/apps/sim/app/playground/org/components/new-chat-home.tsx +++ b/apps/sim/app/playground/org/components/new-chat-home.tsx @@ -27,7 +27,7 @@ const NO_PROJECT = 'none' export function NewChatHome() { const router = useRouter() const { data: session } = useSession() - const { projects } = useProjects() + const { roots: projects } = useProjects() const [{ project }, setParams] = useQueryStates(protoParsers) /** The first project is the default so a fresh visit lands in a project, not an org-wide chat. */ const selected = diff --git a/apps/sim/app/playground/org/components/proto-sidebar.tsx b/apps/sim/app/playground/org/components/proto-sidebar.tsx index cf9f309f19b..ab19356d303 100644 --- a/apps/sim/app/playground/org/components/proto-sidebar.tsx +++ b/apps/sim/app/playground/org/components/proto-sidebar.tsx @@ -120,7 +120,7 @@ export function ProtoSidebar() { const isCollapsed = railCollapsed && !isPeeking const pathname = usePathname() const [{ chat: openChat }] = useQueryStates(protoParsers) - const { projects } = useProjects() + const { projects, roots } = useProjects() const { data: organization } = useActiveOrganization() const { data: session } = useSession() const fullProject = projects.find((project) => project.id === fullViewProject(pathname)) @@ -246,7 +246,7 @@ export function ProtoSidebar() { } >
    - {projects.map((project) => ( + {roots.map((project) => ( (null) const expanded = override ?? inProject - const { data: realChats } = useMothershipChats(project.isMock ? undefined : project.id) + const { data: realChats } = useMothershipChats(project.isMock ? undefined : chatWorkspaceId) const chats = toChatItems(project, realChats) const visible = showAll ? chats : chats.slice(0, CHAT_PREVIEW) const panelHref = (chatId: string) => diff --git a/apps/sim/app/playground/org/components/workspace-view.tsx b/apps/sim/app/playground/org/components/workspace-view.tsx index 854166095fc..6b6c9e40e19 100644 --- a/apps/sim/app/playground/org/components/workspace-view.tsx +++ b/apps/sim/app/playground/org/components/workspace-view.tsx @@ -3,6 +3,7 @@ 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 { 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' @@ -37,7 +38,8 @@ export function WorkspaceView({ project, section, full, settingsSection }: Works {full && section === 'settings' ? null : full ? (
    -

    {current.label}

    +

    {current.label}

    +
    ) : (
    @@ -48,13 +50,16 @@ export function WorkspaceView({ project, section, full, settingsSection }: Works
    - - Open full view - +
    + + + Open full view + +
  • ) } @@ -141,7 +136,7 @@ interface LineageStripProps { */ export function LineageStrip({ project, columns, lineageByEnv, canManage }: LineageStripProps) { const { billingEnabled } = useDeploymentShape() - const { data: creationPolicy } = useWorkspaceCreationPolicy(!project.isMock) + const { data: creationPolicy } = useWorkspaceCreationPolicy() const { navigateToSettings } = useSettingsNavigation() const unlink = useUnlinkFork() const [forkModalOpen, setForkModalOpen] = useState(false) @@ -196,7 +191,6 @@ export function LineageStrip({ project, columns, lineageByEnv, canManage }: Line current={column.id === project.id} lineage={lineageByEnv.get(column.id)} canManage={canManage} - isMock={project.isMock} onDisconnect={() => { if (parent) setConfirmUnlink({ column, parent }) }} @@ -206,19 +200,17 @@ export function LineageStrip({ project, columns, lineageByEnv, canManage }: Line })} - {project.isMock ? null : ( - ({ id: column.id, name: column.name }))} - canFork={creationPolicy?.canCreate ?? true} - onUpgrade={() => { - if (billingEnabled) navigateToSettings({ section: 'billing' }) - }} - /> - )} + ({ id: column.id, name: column.name }))} + canFork={creationPolicy?.canCreate ?? true} + onUpgrade={() => { + if (billingEnabled) navigateToSettings({ section: 'billing' }) + }} + /> (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 ( +
    +
    + {chatId && ( + void setParams({ chat: null, q: null }, { history: 'replace' })} + /> + )} +
    +
    {children}
    +
    + ) +} + +interface SideChatProps { + project: Project + chatId: string + initialMessage?: string + onChatCreated: (chatId: string) => void + onInitialMessageSent: () => void + onClose: () => void +} + +function SideChat({ + project, + chatId, + initialMessage, + onChatCreated, + onInitialMessageSent, + onClose, +}: SideChatProps) { + const { data: chats } = useMothershipChats(project.id) + const chat = chats?.find((candidate) => candidate.id === chatId) + const title = chat?.name ?? (chatId === NEW_CHAT ? `New chat in ${project.name}` : 'Chat') + + return ( +
    +
    + {chat?.isActive && ( + + )} + {title} + +
    + +
    + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-dashboard.tsx b/apps/sim/app/o/[organizationId]/p/components/project-dashboard.tsx new file mode 100644 index 00000000000..bcfeeeed968 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-dashboard.tsx @@ -0,0 +1,75 @@ +'use client' + +import type { ReactNode } from 'react' +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@sim/emcn' +import { Check, ChevronDown } from '@sim/emcn/icons' +import { useQueryStates } from 'nuqs' +import { DashboardFile } from '@/app/o/[organizationId]/p/components/dashboard-file' +import { RunsDashboard } from '@/app/o/[organizationId]/p/components/runs-dashboard' +import { useProjectResources } from '@/app/o/[organizationId]/p/hooks/use-project-resources' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { projectParsers } from '@/app/o/[organizationId]/p/search-params' + +const RUNS_ID = 'runs' + +interface DashboardOption { + id: string + title: string + kind: 'file' | 'runs' +} + +/** The project's dashboards: its `.dashboard` files rendered live, then a built-in Runs dashboard. */ +export function ProjectDashboard({ project }: { project: Project }) { + const [{ dashboard: selected }, setParams] = useQueryStates(projectParsers) + const { dashboardFiles, filesPending } = useProjectResources(project.id) + + if (filesPending) + return

    Loading…

    + + const options: DashboardOption[] = [ + ...dashboardFiles.map((file) => ({ id: file.id, title: file.name, kind: 'file' as const })), + { id: RUNS_ID, title: 'Runs', kind: 'runs' }, + ] + const current = options.find((option) => option.id === selected) ?? options[0] + + const title = ( + + + + + + {options.map((option) => ( + void setParams({ dashboard: option.id }, { history: 'replace' })} + > + {option.title} + {option.id === current.id && } + + ))} + + + ) + + return +} + +interface DashboardBodyProps { + project: Project + option: DashboardOption + title: ReactNode +} + +function DashboardBody({ project, option, title }: DashboardBodyProps) { + const { dashboardFiles } = useProjectResources(project.id) + if (option.kind === 'runs') return + const file = dashboardFiles.find((candidate) => candidate.id === option.id) + if (!file) return null + return +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-page.tsx b/apps/sim/app/o/[organizationId]/p/components/project-page.tsx new file mode 100644 index 00000000000..a47a30b9fd4 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-page.tsx @@ -0,0 +1,29 @@ +'use client' + +import { notFound } from 'next/navigation' +import { ProjectChatPanel } from '@/app/o/[organizationId]/p/components/project-chat-panel' +import { ProjectView } from '@/app/o/[organizationId]/p/components/project-view' +import { useProject } from '@/app/o/[organizationId]/p/hooks/use-projects' +import type { ProjectSection } from '@/app/o/[organizationId]/p/routes' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' + +interface ProjectPageProps { + workspaceId: string + section: ProjectSection +} + +/** Resolves the organization's workspace behind the route as a project, then renders its section. */ +export function ProjectPage({ workspaceId, section }: ProjectPageProps) { + const { organization } = useOrganizationContext() + const { project, isPending } = useProject(organization.id, workspaceId) + if (!project) { + if (isPending) + return

    Loading project…

    + notFound() + } + return ( + + + + ) +} diff --git a/apps/sim/app/o/[organizationId]/p/components/project-view.tsx b/apps/sim/app/o/[organizationId]/p/components/project-view.tsx new file mode 100644 index 00000000000..77e7354568c --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/components/project-view.tsx @@ -0,0 +1,99 @@ +'use client' + +import { ChipLink } from '@sim/emcn' +import { Expand } from '@sim/emcn/icons' +import { organizationRoutes } from '@/lib/navigation/paths' +import { EnvironmentSwitcher } from '@/app/o/[organizationId]/p/components/environment-switcher' +import { EnvironmentsTab } from '@/app/o/[organizationId]/p/components/environments' +import { ProjectDashboard } from '@/app/o/[organizationId]/p/components/project-dashboard' +import { useProjectDescription } from '@/app/o/[organizationId]/p/hooks/use-project-resources' +import type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { + PROJECT_SECTIONS, + type ProjectSection, + workspaceRoutes, +} from '@/app/o/[organizationId]/p/routes' +import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' + +interface ProjectViewProps { + project: Project + section: ProjectSection +} + +/** A project's main view: its name, the environment, and a chip per section. Build opens the workspace. */ +export function ProjectView({ project, section }: ProjectViewProps) { + const { organization } = useOrganizationContext() + const routes = organizationRoutes(organization.id) + return ( +
    +
    +
    +
    +

    {project.name}

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

    {text}

    +} + +interface SectionBodyProps { + project: Project + section: ProjectSection +} + +function SectionBody({ project, section }: SectionBodyProps) { + switch (section) { + case 'dashboard': + return ( +
    + +
    + ) + case 'changelog': + return No releases yet. + case 'issues': + return No issues yet. + case 'environments': + return + } +} + +/** Changelog and Issues until their entities exist. */ +function EmptySection({ children }: { children: string }) { + return

    {children}

    +} diff --git a/apps/sim/app/playground/org/components/runs-dashboard.tsx b/apps/sim/app/o/[organizationId]/p/components/runs-dashboard.tsx similarity index 98% rename from apps/sim/app/playground/org/components/runs-dashboard.tsx rename to apps/sim/app/o/[organizationId]/p/components/runs-dashboard.tsx index 1c685e7eaf0..c6bce6e9e09 100644 --- a/apps/sim/app/playground/org/components/runs-dashboard.tsx +++ b/apps/sim/app/o/[organizationId]/p/components/runs-dashboard.tsx @@ -3,8 +3,8 @@ 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 type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' +import { workspaceRoutes } from '@/app/o/[organizationId]/p/routes' import { formatLatency } from '@/app/workspace/[workspaceId]/logs/utils' import { useDashboardStats } from '@/hooks/queries/logs' import type { TimeRange } from '@/stores/logs/filters/types' diff --git a/apps/sim/app/playground/org/lib/use-project-resources.ts b/apps/sim/app/o/[organizationId]/p/hooks/use-project-resources.ts similarity index 83% rename from apps/sim/app/playground/org/lib/use-project-resources.ts rename to apps/sim/app/o/[organizationId]/p/hooks/use-project-resources.ts index b32c9c02010..60fdebd60bf 100644 --- a/apps/sim/app/playground/org/lib/use-project-resources.ts +++ b/apps/sim/app/o/[organizationId]/p/hooks/use-project-resources.ts @@ -1,7 +1,6 @@ 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 type { Project } from '@/app/o/[organizationId]/p/hooks/use-projects' import { useFolderMap } from '@/hooks/queries/folders' import { useKnowledgeBasesQuery } from '@/hooks/queries/kb/knowledge' import { useTablesList } from '@/hooks/queries/tables' @@ -11,7 +10,6 @@ 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) @@ -58,13 +56,9 @@ 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. */ +/** What the project's workspace holds, as its description line. */ export function useProjectDescription(project: Project): string { - const description = useProjectSources().descriptionFor(project) - const { workflows, tables, knowledgeBases, files, isPending } = useProjectResources( - realWorkspaceId(project) - ) - if (description) return description + const { workflows, tables, knowledgeBases, files, isPending } = useProjectResources(project.id) if (isPending) return '' return [ plural(workflows.length, 'workflow'), diff --git a/apps/sim/app/o/[organizationId]/p/hooks/use-projects.ts b/apps/sim/app/o/[organizationId]/p/hooks/use-projects.ts new file mode 100644 index 00000000000..b1976ff77d0 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/hooks/use-projects.ts @@ -0,0 +1,46 @@ +import { type ProjectEnvironment, resolveProjectLineages } from '@/lib/projects' +import { useWorkspacesQuery } from '@/hooks/queries/workspace' + +/** A workspace seen as a project: its fork lineage, with this workspace as one environment. */ +export interface Project { + /** The workspace this view is on; every route and hook uses it. */ + id: string + name: string + organizationId: string | null + /** The workspace at the top of the lineage; lists show 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 organization's projects: every workspace it hosts, grouped by fork lineage. */ +export function useProjects(organizationId: string) { + const query = useWorkspacesQuery() + const workspaces = (query.data ?? []).filter( + (workspace) => workspace.organizationId === organizationId + ) + /** A handful of rows, so deriving them each render is cheaper than a memo keyed on the list. */ + const lineages = resolveProjectLineages(workspaces) + const projects: 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, + rootId: lineage.rootId, + environment: lineage.environment, + environments: lineage.environments, + } + }) + /** One entry per project for the sidebar and pickers: each lineage's root. */ + const roots = projects.filter((project) => project.rootId === project.id) + return { projects, roots, isPending: query.isPending, error: query.error } +} + +export function useProject(organizationId: string, workspaceId: string) { + const { projects, isPending, error } = useProjects(organizationId) + return { project: projects.find((project) => project.id === workspaceId), isPending, error } +} diff --git a/apps/sim/app/o/[organizationId]/p/routes.ts b/apps/sim/app/o/[organizationId]/p/routes.ts new file mode 100644 index 00000000000..e40e9c970ed --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/routes.ts @@ -0,0 +1,22 @@ +import { Layout, ListChecks, Rss, Server } from '@sim/emcn/icons' + +/** The project main view's sections, in the order its chips read. */ +export const PROJECT_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 }, +] as const + +export type ProjectSection = (typeof PROJECT_SECTIONS)[number]['id'] + +export function isProjectSection(value: string): value is ProjectSection { + return PROJECT_SECTIONS.some((section) => section.id === value) +} + +/** The 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}`, +} diff --git a/apps/sim/app/o/[organizationId]/p/search-params.ts b/apps/sim/app/o/[organizationId]/p/search-params.ts new file mode 100644 index 00000000000..919d46d3300 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/p/search-params.ts @@ -0,0 +1,15 @@ +import { parseAsString, parseAsStringLiteral } from 'nuqs/server' +import { RESOURCE_TAB_IDS } from '@/app/o/[organizationId]/p/components/environments/mapping-model' + +export const projectParsers = { + /** Chat open in the side panel next to the project; `new` before the first message. */ + chat: parseAsString.withDefault(''), + /** Project a new Build chat starts in from Home; `none` means an organization chat. */ + project: parseAsString.withDefault(''), + /** First message handed from Home to a project's new chat; sent once, then cleared. */ + q: parseAsString.withDefault(''), + /** Dashboard shown on the project page: a dashboard file id, or `runs`. */ + dashboard: parseAsString.withDefault(''), + /** Resource type shown in the Environments tab: workflows, then the mapping kinds. */ + resource: parseAsStringLiteral(RESOURCE_TAB_IDS).withDefault('workflow'), +} diff --git a/apps/sim/app/playground/org/[[...slug]]/page.tsx b/apps/sim/app/playground/org/[[...slug]]/page.tsx deleted file mode 100644 index 00e29cd3fcf..00000000000 --- a/apps/sim/app/playground/org/[[...slug]]/page.tsx +++ /dev/null @@ -1,36 +0,0 @@ -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 deleted file mode 100644 index 7575b74c6e9..00000000000 --- a/apps/sim/app/playground/org/components/agent-run.tsx +++ /dev/null @@ -1,151 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { Avatar, cn } from '@sim/emcn' -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 { 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 - -function formatElapsed(total: number): string { - const minutes = Math.floor(total / 60) - const seconds = total % 60 - return minutes ? `${minutes}m ${String(seconds).padStart(2, '0')}s` : `${seconds}s` -} - -interface AgentRunProps { - work: RunningWork - 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, project }: AgentRunProps) { - const [seconds, setSeconds] = useState(0) - const [autoApprove, setAutoApprove] = useState(false) - const [stopped, setStopped] = useState(false) - const [draft, setDraft] = useState('') - const [steer, setSteer] = useState(null) - - useEffect(() => { - if (stopped) return - const timer = window.setInterval(() => setSeconds((s) => s + 1), 1000) - return () => window.clearInterval(timer) - }, [stopped]) - - const elapsed = work.startedMinutesAgo * 60 + seconds - const shown = Math.min(work.narration.length, 1 + Math.floor(seconds / NARRATION_INTERVAL_S)) - const owner = work.chat.owner.split(' ')[0] - - return ( -
    - - {stopped - ? `Stopped after ${formatElapsed(elapsed)} · ${work.task}` - : `Working for ${formatElapsed(elapsed)} · ${work.task}`} - -
    - {work.narration.slice(0, shown).map((line, index) => ( -

    - {line} -

    - ))} - {steer && ( -

    - {steer} -

    - )} - {!stopped && Thinking} -
    - -
    - setDraft(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter' && draft.trim()) { - setSteer(draft.trim()) - setDraft('') - } - }} - placeholder={stopped ? 'Stopped' : `Steer ${work.agent}…`} - className='w-full bg-transparent text-[var(--text-body)] text-md outline-none placeholder:text-[var(--text-muted)]' - /> -
    - - - - - - {work.issue} - - - - - - {`${owner}’s chat`} - - - - -
    -
    -
    - ) -} diff --git a/apps/sim/app/playground/org/components/changelog.tsx b/apps/sim/app/playground/org/components/changelog.tsx deleted file mode 100644 index 578331652c4..00000000000 --- a/apps/sim/app/playground/org/components/changelog.tsx +++ /dev/null @@ -1,468 +0,0 @@ -'use client' - -import { useEffect, useState } from 'react' -import { Avatar, Chip, ChipTag, cn, DashboardMetric, toast } from '@sim/emcn' -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 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' -const BEFORE_COLOR = '#A4A9B1' -const AFTER_COLOR = '#5C7399' -const H2 = - '@min-[1000px]/dashboard:text-[24px] text-[20px] text-[var(--text-primary)] leading-tight tracking-[-0.02em]' - -/** - * 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 release = (draftRelease: DraftRelease) => { - const ready = draftRelease.changes.filter((change) => change.state === 'ready') - const workflowsShipped = [...new Set(ready.map((change) => change.workflow))] - setReleased({ - id: 'released-now', - workspaceId: draftRelease.workspaceId, - date: TODAY, - version: draftRelease.versions - .filter((v) => workflowsShipped.includes(v.workflow)) - .map((v) => v.to) - .join(' · '), - workflows: workflowsShipped, - title: draftRelease.release.title, - summary: draftRelease.release.summary, - changes: ready, - metrics: [], - reporters: draftRelease.release.reporters, - channel: draftRelease.release.channel, - live: { label: 'runs since release', goodLabel: 'errors' }, - }) - toast.success(`Released ${ready.length} changes`) - } - - return ( -
    -
    -
    -
    -

    - Changelog -

    -

    - {MODE_COPY[mode](project.name)} -

    -
    -
    - - {mode === 'shipped' && - (shipped.length > 0 ? ( - shipped.map((entry) => ) - ) : ( - Nothing has shipped here yet. - ))} - - {mode === 'draft' && - (draft ? ( - <> - release(draft)} - /> - {released && } - - ) : ( - No release is being drafted. - ))} - - {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 -} - -/** 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') - return ( -
    -
    -
    -
    -

    Next release

    - Draft -
    -

    - {draft.versions.map((v) => `${v.workflow} ${v.from} → ${v.to}`).join(' · ')} -

    -
    - - {readyCount === 0 ? 'Nothing ready' : `Release ${readyCount} changes`} - -
    - -
    - 0 - ? `${held.length} held until approved, the rest ship together` - : undefined - } - /> - {changes.length === 0 ? ( -

    Everything ready has shipped.

    - ) : ( -
      - {changes.map((change) => ( - - ))} -
    - )} -
    - - -
    - ) -} - -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 ( -
    - {title} - {aside && {aside}} -
    - ) -} - -interface DraftChangeRowProps { - change: DraftRelease['changes'][number] - workspaceId: string -} - -function DraftChangeRow({ change, workspaceId }: DraftChangeRowProps) { - const needsApproval = change.state === 'approval' - return ( -
  • - - {needsApproval ? ( - - ) : ( - - )} - {change.text} - {needsApproval && ( - Needs approval - )} - -
  • - ) -} - -function Release({ entry, projectId }: { entry: ChangelogEntry; projectId: string }) { - const fresh = entry.id === 'released-now' - return ( -
    - - -
    -
    -

    {entry.title}

    -

    - {entry.summary} -

    -
    - -
      - {entry.changes.map((change) => ( -
    • - - - {change.text} - -
    • - ))} -
    - - {entry.metrics.length > 0 && ( -
    - {entry.metrics.map((metric) => ( - - ))} -
    - )} - - {entry.chart ? ( - - ) : ( -

    - Impact charts appear after a day of runs. -

    - )} - - {entry.live && } - - {entry.reporters.length > 0 && ( -
    -
    - {entry.reporters.map((name) => ( - - ))} -
    - {`Replied to ${entry.reporters.join(', ')} in`} - - - {entry.channel} - -
    - )} -
    -
    - ) -} - -function Metric({ metric }: { metric: ChangelogMetric }) { - const change = - metric.before === 0 ? null : Math.round(((metric.after - metric.before) / metric.before) * 100) - const improved = - change === null || change === 0 ? null : metric.lowerIsBetter ? change < 0 : change > 0 - return ( -
    - - - {change === null || change === 0 - ? 'unchanged' - : `${change > 0 ? '+' : ''}${change}% · was ${metric.before}${metric.unit ?? ''}`} - -
    - ) -} - -function dayLabel(date: string, offset: number): string { - const day = new Date(`${date} ${RELEASE_YEAR} 00:00:00 UTC`) - day.setUTCDate(day.getUTCDate() + offset) - return day.toLocaleDateString('en-US', { month: 'short', day: 'numeric', timeZone: 'UTC' }) -} - -interface ReleaseChartProps { - chart: ChangelogChart - date: string - marker: string - shaded: boolean -} - -function ReleaseChart({ chart, date, marker, shaded }: ReleaseChartProps) { - const labels = chart.values.map((_, index) => dayLabel(date, index - chart.releaseIndex)) - const releaseLabel = labels[chart.releaseIndex] - const data = chart.values.map((value, index) => - chart.kind === 'bar' - ? { - value, - itemStyle: { color: shaded && index >= chart.releaseIndex ? AFTER_COLOR : BEFORE_COLOR }, - } - : value - ) - const option = { - grid: { left: 8, right: 56, top: 28, bottom: 4, containLabel: true }, - tooltip: { trigger: 'axis' }, - xAxis: { - type: 'category', - data: labels, - axisTick: { show: false }, - axisLabel: { interval: 3 }, - boundaryGap: chart.kind === 'bar', - }, - yAxis: { - type: 'value', - splitNumber: 3, - axisLabel: chart.unit ? { formatter: `{value}${chart.unit}` } : undefined, - }, - series: [ - { - type: chart.kind, - name: chart.title, - data, - barWidth: '56%', - smooth: 0.25, - showSymbol: false, - lineStyle: { width: 2, color: AFTER_COLOR }, - itemStyle: { color: AFTER_COLOR }, - areaStyle: chart.kind === 'line' ? { opacity: 0.06, color: AFTER_COLOR } : undefined, - markLine: { - symbol: 'none', - silent: true, - lineStyle: { type: 'dashed', color: BEFORE_COLOR }, - label: { formatter: marker, position: 'end', color: BEFORE_COLOR }, - data: [{ xAxis: releaseLabel }], - }, - markArea: shaded - ? { - silent: true, - itemStyle: { color: AFTER_COLOR, opacity: 0.07 }, - label: { - show: true, - position: 'insideTop', - color: AFTER_COLOR, - formatter: 'After release', - }, - data: [[{ xAxis: releaseLabel }, { xAxis: labels[labels.length - 1] }]], - } - : undefined, - }, - ], - } - return ( -
    -
    - {chart.title} - - {shaded ? 'two weeks around the release' : 'last two weeks'} - -
    -
    - -
    -
    - ) -} - -interface LiveSinceReleaseProps { - live: NonNullable - start: number -} - -/** Ticks while the page is open, standing in for a live query against the run log. */ -function LiveSinceRelease({ live, start }: LiveSinceReleaseProps) { - const [runs, setRuns] = useState(start) - useEffect(() => { - const timer = window.setInterval(() => setRuns((count) => count + 1), 2400) - return () => window.clearInterval(timer) - }, []) - return ( -
    - - - - - {`${runs.toLocaleString()} ${live.label}`} - · - {`0 ${live.goodLabel}`} - Live -
    - ) -} diff --git a/apps/sim/app/playground/org/components/chat-thread.tsx b/apps/sim/app/playground/org/components/chat-thread.tsx deleted file mode 100644 index 3bd942f22ad..00000000000 --- a/apps/sim/app/playground/org/components/chat-thread.tsx +++ /dev/null @@ -1,60 +0,0 @@ -'use client' - -import { useState } from 'react' -import { cn } from '@sim/emcn' -import { MockComposer } from '@/app/playground/org/components/mock-composer' - -interface Message { - role: 'user' | 'assistant' - text: string -} - -const SEED: Message[] = [ - { role: 'user', text: 'Why is churn-agent slow on batch 3?' }, - { - role: 'assistant', - text: 'Batch 3 has 214 accounts with no plan field, so each one falls back to a full usage scan. I can backfill plan from Stripe first — want me to add that as a step on SUP-131?', - }, -] - -interface ChatThreadProps { - placeholder: string - className?: string -} - -/** Static chat thread; replies are canned so the layout can be judged without Mothership. */ -export function ChatThread({ placeholder, className }: ChatThreadProps) { - const [messages, setMessages] = useState(SEED) - return ( -
    -
    - {messages.map((message, index) => - message.role === 'user' ? ( -
    - {message.text} -
    - ) : ( -

    - {message.text} -

    - ) - )} -
    -
    - - setMessages((prev) => [ - ...prev, - { role: 'user', text }, - { role: 'assistant', text: 'On it — this is a prototype, so this reply is canned.' }, - ]) - } - /> -
    -
    - ) -} diff --git a/apps/sim/app/playground/org/components/delegate-avatar.tsx b/apps/sim/app/playground/org/components/delegate-avatar.tsx deleted file mode 100644 index 9617f26d442..00000000000 --- a/apps/sim/app/playground/org/components/delegate-avatar.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Avatar, cn } from '@sim/emcn' -import { Sim, Workflow, Zap } from '@sim/emcn/icons' -import type { Delegate, Person } from '@/app/playground/org/lib/types' - -const DELEGATE_ICON = { agent: Zap, workflow: Workflow, sim: Sim } as const - -interface DelegateAvatarProps { - owner: Person - delegate?: Delegate - className?: string -} - -/** Owner avatar; a delegated issue carries a small badge for the agent working it. */ -export function DelegateAvatar({ owner, delegate, className }: DelegateAvatarProps) { - const Icon = delegate ? DELEGATE_ICON[delegate.kind] : null - return ( - - - {Icon && ( - - - - )} - - ) -} - -export function delegateIcon(delegate: Delegate) { - return DELEGATE_ICON[delegate.kind] -} diff --git a/apps/sim/app/playground/org/components/environments/index.ts b/apps/sim/app/playground/org/components/environments/index.ts deleted file mode 100644 index a528794d847..00000000000 --- a/apps/sim/app/playground/org/components/environments/index.ts +++ /dev/null @@ -1,42 +0,0 @@ -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/mock-mappings.ts b/apps/sim/app/playground/org/components/environments/mock-mappings.ts deleted file mode 100644 index 74cdf633d5c..00000000000 --- a/apps/sim/app/playground/org/components/environments/mock-mappings.ts +++ /dev/null @@ -1,87 +0,0 @@ -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/glyphs.tsx b/apps/sim/app/playground/org/components/glyphs.tsx deleted file mode 100644 index 49b98a185ef..00000000000 --- a/apps/sim/app/playground/org/components/glyphs.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { cn } from '@sim/emcn' -import { - Bell, - CircleAlert, - CircleCheck, - CircleX, - Clock, - Mail, - MessageSquareText, -} from '@sim/emcn/icons' -import { SlackMonoIcon } from '@/components/icons' -import type { - AgentState, - FeedbackSource, - IssueStatus, - Priority, -} from '@/app/playground/org/lib/types' - -const GLYPH = 'size-[14px] shrink-0' - -/** Linear-style status circle: dashed backlog, empty todo, half in-progress, filled done. */ -export function StatusIcon({ status, className }: { status: IssueStatus; className?: string }) { - if (status === 'done') - return - if (status === 'canceled') - return - return ( - - - {status === 'in_progress' && ( - - )} - {status === 'blocked' && ( - - )} - - ) -} - -/** Signal bars for High/Medium/Low, a filled square for Urgent, dashes for none. */ -export function PriorityIcon({ priority, className }: { priority: Priority; className?: string }) { - if (priority === 1) - return ( - - - - - ) - const filled = priority === 0 ? 0 : 5 - priority - return ( - - {[0, 1, 2].map((bar) => - priority === 0 ? ( - - ) : ( - - ) - )} - - ) -} - -/** Agent session state, mirroring Linear's pending/active/awaitingInput/error/complete/stale. */ -export function AgentStateIcon({ state, className }: { state: AgentState; className?: string }) { - switch (state) { - case 'active': - return - case 'awaitingInput': - return - case 'error': - return - case 'complete': - return - case 'stale': - return - case 'pending': - return - } -} - -export function SourceIcon({ source, className }: { source: FeedbackSource; className?: string }) { - if (source === 'slack') - return - if (source === 'pagerduty') - return - if (source === 'email') - return - return -} - -/** An agent working right now: a yellow dot, used everywhere a run is in progress. */ -export function RunningDot({ className }: { className?: string }) { - return ( - - - - ) -} diff --git a/apps/sim/app/playground/org/components/investigation/evidence.tsx b/apps/sim/app/playground/org/components/investigation/evidence.tsx deleted file mode 100644 index aa2c65bc0fd..00000000000 --- a/apps/sim/app/playground/org/components/investigation/evidence.tsx +++ /dev/null @@ -1,158 +0,0 @@ -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/types' - -const MONO = 'font-mono text-caption' - -/** One piece of investigation evidence: a run trace, a slice of the run log, or a block diff. */ -export function EvidenceView({ evidence }: { evidence: Evidence }) { - switch (evidence.type) { - case 'run': - return ( - - - - ) - case 'runs': - return ( - - - - - {evidence.columns.map((column) => ( - {column} - ))} - - - - {evidence.rows.map((row, index) => ( - - {row.map((cell, cellIndex) => ( - - {cell} - - ))} - - ))} - -
    -
    - ) - case 'diff': - return ( - - - - ) - } -} - -export function DiffLines({ lines }: { lines: { kind: 'add' | 'del' | 'ctx'; text: string }[] }) { - return ( -
    - {lines.map((line, index) => ( -
    - {line.kind === 'add' ? '+ ' : line.kind === 'del' ? '- ' : ' '} - {line.text} -
    - ))} -
    - ) -} - -interface EvidenceFrameProps { - title: string - subtitle: string - action?: string - children: React.ReactNode -} - -function EvidenceFrame({ title, subtitle, action, children }: EvidenceFrameProps) { - return ( -
    -
    - {title} - - {subtitle} - - {action && ( - - {action} - - )} -
    - {children} -
    - ) -} - -/** Block-by-block run trace, the same shape as the Logs trace view. */ -function RunTrace({ blocks }: { blocks: RunBlock[] }) { - const total = Math.max(...blocks.map((block) => block.start + block.duration)) - return ( -
    - {blocks.map((block) => ( -
    - - {block.name} - - {block.type} -
    -
    -
    - - {block.duration.toLocaleString()} ms - - {block.output && ( - - {block.output} - - )} -
    - ))} -
    - ) -} diff --git a/apps/sim/app/playground/org/components/investigation/investigation-view.tsx b/apps/sim/app/playground/org/components/investigation/investigation-view.tsx deleted file mode 100644 index 5cc5e65d75b..00000000000 --- a/apps/sim/app/playground/org/components/investigation/investigation-view.tsx +++ /dev/null @@ -1,512 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Avatar, Chip, ChipTag, cn, toast } from '@sim/emcn' -import { - ArrowUpRight, - ChevronDown, - CircleCheck, - Clock, - Database, - Library, - MessageSquareText, - RefreshCw, - Rocket, - SlidersHorizontal, - Workflow, -} from '@sim/emcn/icons' -import { ConfluenceIcon, SlackMonoIcon } from '@/components/icons' -import { AgentStateIcon } from '@/app/playground/org/components/glyphs' -import { DiffLines, EvidenceView } from '@/app/playground/org/components/investigation/evidence' -import type { - Investigation, - InvestigationStep, - SourceMessage, -} from '@/app/playground/org/lib/types' - -const STEP_ICONS: Record> = { - feedback: MessageSquareText, - check: Database, - workflow: Workflow, - runs: Library, - run: Library, - config: SlidersHorizontal, - history: Clock, - conclusion: CircleCheck, -} - -const EVIDENCE_LABEL = { run: 'run', runs: 'runs', diff: 'change' } as const - -type Decision = 'pending' | 'redeployed' | 'rejected' - -const SECTION_IDS = [ - 'summary', - 'report', - 'documents', - 'rootCause', - 'frequency', - 'proposal', - 'investigation', -] as const -type SectionId = (typeof SECTION_IDS)[number] - -/** An agent investigation of a workflow, read top-down, ending in a redeploy approval. */ -export function InvestigationView({ investigation }: { investigation: Investigation }) { - const [decision, setDecision] = useState('pending') - const { origin, documents } = investigation - - const sections: Record = { - summary: { - id: 'summary', - title: 'Summary', - children:
    , - }, - report: { - id: 'report', - title: origin.kind === 'slack' ? 'Reported in Slack' : 'Detected by a knowledge check', - children: - origin.kind === 'slack' ? ( - - ) : ( - - ), - }, - documents: { - id: 'documents', - title: 'Contradicting documents', - aside: documents ? `${documents.length} Confluence pages` : undefined, - children: documents ? : null, - }, - rootCause: { - id: 'rootCause', - title: 'Root cause', - aside: {investigation.rootCause.confidence} confidence, - children: , - }, - frequency: { - id: 'frequency', - title: 'Seen in production', - aside: ( - - Re-check - - ), - children: , - }, - proposal: { - id: 'proposal', - title: 'Proposed fix', - children: , - }, - investigation: { - id: 'investigation', - title: 'Investigation', - aside: `${investigation.timeline.length} steps · 6 min`, - children: , - }, - } - - return ( -
    - - {SECTION_IDS.filter((id) => id !== 'documents' || documents).map((id) => ( -
    - ))} -
    - ) -} - -function Summary({ investigation }: { investigation: Investigation }) { - return ( -
    - {investigation.summary - .filter((row) => row.label !== 'Fix') - .map((row) => ( -
    - {row.label} - {row.text} -
    - ))} -
    - ) -} - -interface StatusStripProps { - investigation: Investigation - decision: Decision - onDecide: (decision: Decision) => void -} - -/** The one decision on the page, pinned at the top: redeploy the fix or reject it. */ -function StatusStrip({ investigation, decision, onDecide }: StatusStripProps) { - const { workflow, proposal } = investigation - if (proposal.docEdit) - return ( -
    - - - {`infra-analyzer stopped before editing ${proposal.docEdit.page} in Confluence.`} - -
    - ) - if (decision === 'redeployed') - return ( -
    - - - {`${workflow.name} ${workflow.nextVersion} is live. infra-analyzer is watching new runs.`} - -
    - ) - if (decision === 'rejected') - return ( -

    - Fix rejected. The investigation stays on record; the workflow was not changed. -

    - ) - return ( -
    - - - {`infra-analyzer stopped before changing ${workflow.name}.`} - - onDecide('rejected')}>Reject - { - onDecide('redeployed') - toast.success(`${workflow.name} ${workflow.nextVersion} deployed`) - }} - > - {`Redeploy as ${workflow.nextVersion}`} - -
    - ) -} - -interface SectionProps { - id: string - title: string - aside?: React.ReactNode - children: React.ReactNode -} - -function Section({ id, title, aside, children }: SectionProps) { - return ( -
    -
    -

    {title}

    - {aside &&
    {aside}
    } -
    - {children} -
    - ) -} - -type KbOrigin = Extract - -/** The automatic query that surfaced the problem, with the answers that disagreed. */ -function KnowledgeCheck({ origin }: { origin: KbOrigin }) { - return ( -
    -
    - - {origin.knowledgeBase} - {`· knowledge check · ${origin.at}`} -
    -

    {`“${origin.query}”`}

    -
    - {origin.answers.map((answer) => ( -
    - - {answer.answer} - {answer.cites} -
    - ))} -
    -

    {origin.detail}

    -
    - ) -} - -/** The source pages side by side, the conflicting sentence marked in each. */ -function Documents({ documents }: { documents: NonNullable }) { - return ( -
    - {documents.map((doc) => ( -
    -
    - -
    - {doc.title} - - {`${doc.space} · ${doc.author} · ${doc.updated}`} - -
    - -
    -
    - {doc.excerpt.map((line) => - line === doc.highlight ? ( - - {line} - - ) : ( -

    - {line} -

    - ) - )} -
    -
    - ))} -
    - ) -} - -function SlackThread({ trigger, thread }: { trigger: SourceMessage; thread: SourceMessage[] }) { - const [open, setOpen] = useState(false) - return ( -
    - - - {open && ( -
    - {thread.map((message, index) => ( - - ))} -
    - )} -
    - ) -} - -function SlackMessage({ message }: { message: SourceMessage }) { - return ( -
    - -
    -
    - {message.author} - - - {message.channel} · {message.at} - - -
    -

    {message.text}

    -
    -
    - ) -} - -/** Collapsed to one line by default; the summary and root cause carry the story. */ -function Timeline({ steps }: { steps: InvestigationStep[] }) { - const [expanded, setExpanded] = useState(false) - const [openSteps, setOpenSteps] = useState>(() => new Set()) - const toggle = (index: number) => - setOpenSteps((prev) => { - const next = new Set(prev) - if (next.has(index)) next.delete(index) - else next.add(index) - return next - }) - if (!expanded) - return ( - - ) - return ( -
      - {steps.map((step, index) => { - const Icon = STEP_ICONS[step.kind] - const last = index === steps.length - 1 - const open = openSteps.has(index) - return ( -
    1. - - {step.at} - -
      - - - - {!last && } -
      -
      -
      - {step.title} - {step.evidence && ( - toggle(index)}> - {open ? 'Hide' : 'Show'} {EVIDENCE_LABEL[step.evidence.type]} - - )} -
      - {step.detail &&

      {step.detail}

      } - {step.evidence && open && } -
      -
    2. - ) - })} -
    3. - -
    4. -
    - ) -} - -/** The summary sentence up front; the causal chain and the block live behind Details. */ -function RootCause({ rootCause }: { rootCause: Investigation['rootCause'] }) { - const [open, setOpen] = useState(false) - return ( -
    -

    {rootCause.summary}

    - - {open && ( -
    -
      - {rootCause.chain.map((link, index) => ( -
    1. - - {index + 1} - - {link} -
    2. - ))} -
    -
    -            {`${rootCause.block.path}\n${rootCause.block.lines.join('\n')}`}
    -          
    -

    {rootCause.confidenceReason}

    -
    - )} -
    - ) -} - -/** Stats on one line over a compact bar strip; the full chart lives in Dashboard. */ -function Frequency({ frequency }: { frequency: Investigation['frequency'] }) { - const max = Math.max(...frequency.days.map((day) => day.count), 1) - return ( -
    -

    - {frequency.stats.map((stat, index) => ( - - {index > 0 && ·} - {`${stat.label} `} - {stat.value} - - ))} -

    -
    - {frequency.days.map((day) => ( -
    - ))} -
    -
    - ) -} - -/** Plain words: what changes, what the replay showed, and what people will notice. */ -function ProposedFix({ investigation }: { investigation: Investigation }) { - const { proposal, workflow } = investigation - const { docEdit } = proposal - if (!docEdit) - return ( -

    - {`${proposal.summary} This ships as ${workflow.name} ${workflow.nextVersion}. ${proposal.replay} ${proposal.tradeoff}`} -

    - ) - return ( -
    -

    - {`${proposal.summary} ${proposal.replay}`} -

    -
    -
    - - {docEdit.page} - {docEdit.space} -
    - -
    -

    {proposal.tradeoff}

    -
    - Leave feedback - - Approve and send to chat - -
    -
    - ) -} diff --git a/apps/sim/app/playground/org/components/issue-page.tsx b/apps/sim/app/playground/org/components/issue-page.tsx deleted file mode 100644 index 5adf6b87f4a..00000000000 --- a/apps/sim/app/playground/org/components/issue-page.tsx +++ /dev/null @@ -1,458 +0,0 @@ -'use client' - -import { useState } from 'react' -import { Avatar, Banner, Chip, ChipDropdown, ChipLink, chipVariants, cn } from '@sim/emcn' -import { - ArrowUpRight, - ChevronLeft, - Circle, - CircleCheck, - Database, - Files, - Layout, - Table, - Workflow, - X, -} from '@sim/emcn/icons' -import Link from 'next/link' -import { DelegateAvatar, delegateIcon } from '@/app/playground/org/components/delegate-avatar' -import { - AgentStateIcon, - PriorityIcon, - RunningDot, - SourceIcon, - StatusIcon, -} from '@/app/playground/org/components/glyphs' -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 { - PRIORITY_LABELS, - STATUS_LABELS, - STATUS_ORDER, -} 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, - workflow: Workflow, - dashboard: Layout, - knowledge: Database, - file: Files, -} as const - -interface IssuePageProps { - project: Project - issue: Issue -} - -/** Full-page issue, Linear-style: content column plus a properties column. */ -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 = 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 ( -
    -
    - - {project.name} - - / - {issue.key} -
    - {issue.agent?.state === 'error' && ( - a.kind === 'error')?.text} - actionLabel='Open credentials' - /> - )} -
    -
    -
    - - - {issue.investigation && } - - {issue.research && ( -
    -

    {issue.research.cause}

    -
    - {issue.research.related.map((ref) => ( - - {ref} - - ))} -
    -
    - )} - - {issue.reports.length > 0 && !issue.investigation && ( -
    -
    - {issue.reports.map((report) => ( -
    - - {report.author} - - “{report.text}” - - - {report.channel} · {report.at} - -
    - ))} -
    -
    - )} - - {issue.delegate && !issue.investigation && ( -
    - {issue.agent && } - {issue.delegate.name} - {issue.agent && ` · ${issue.agent.label}`} - - } - > - {issue.plan.length > 0 && ( -
    - {issue.plan.map((step) => ( - - ))} -
    - )} -
    - {activity.map((item, index) => ( - - ))} -
    - - setActivity((prev) => [ - ...prev, - { kind: 'response', text: `You: ${text}`, at: 'now' }, - ]) - } - /> -
    - )} -
    -
    - - -
    -
    - ) -} - -function Section({ - title, - aside, - children, -}: { - title: string - aside?: React.ReactNode - children: React.ReactNode -}) { - return ( -
    -
    - {title} - {aside && {aside}} -
    - {children} -
    - ) -} - -function Property({ label, children }: { label: string; children: React.ReactNode }) { - return ( -
    - {label} -
    {children}
    -
    - ) -} - -function PlanRow({ step }: { step: PlanStep }) { - const Icon = step.status === 'completed' ? CircleCheck : step.status === 'canceled' ? X : Circle - return ( -
    - {step.status === 'inProgress' ? ( - - ) : ( - - )} - - {step.label} - -
    - ) -} - -const ACTIVITY_PREFIX: Record = { - thought: 'Thinking', - action: '', - elicitation: 'Question', - response: '', - error: 'Error', -} - -function ActivityRow({ activity }: { activity: Activity }) { - return ( -
    - - - {ACTIVITY_PREFIX[activity.kind] && ( - {ACTIVITY_PREFIX[activity.kind]}: - )} - {activity.text} - - {activity.at} -
    - ) -} - -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 deleted file mode 100644 index 005a0b2a1fe..00000000000 --- a/apps/sim/app/playground/org/components/issues-list.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'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 { 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({ project }: { project: Project }) { - const issues = useProjectSources().issuesFor(project) - if (!issues.length) - return

    No issues yet.

    - return ( -
    - {STATUS_ORDER.map((status) => { - const inStatus = [...issues] - .filter((issue) => issue.status === status) - .sort((a, b) => (a.priority || 5) - (b.priority || 5)) - if (!inStatus.length) return null - return ( -
    -
    - - {STATUS_LABELS[status]} - {inStatus.length} -
    -
    - {inStatus.map((issue) => ( - - ))} -
    -
    - ) - })} -
    - ) -} - -function IssueRow({ issue, projectId }: { issue: Issue; projectId: string }) { - return ( - - - {issue.key} - - - - {issue.agent && ( - <> - - {issue.agent.label} - - )} - - - - ) -} diff --git a/apps/sim/app/playground/org/components/linked-tickets.tsx b/apps/sim/app/playground/org/components/linked-tickets.tsx deleted file mode 100644 index 405fea42566..00000000000 --- a/apps/sim/app/playground/org/components/linked-tickets.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'use client' - -import { - cn, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@sim/emcn' -import { ArrowUpRight } from '@sim/emcn/icons' -import { GithubIcon, JiraIcon, LinearIcon } from '@/components/icons' -import type { Project } from '@/app/playground/org/lib/project' -import { useProjectSources } from '@/app/playground/org/lib/project-sources' -import type { LinkedTicket } from '@/app/playground/org/lib/types' - -const SYSTEM_ICON = { linear: LinearIcon, jira: JiraIcon, github: GithubIcon } as const -const SYSTEM_LABEL = { linear: 'Linear', jira: 'Jira', github: 'GitHub' } as const - -export function TicketIcon({ ticket, className }: { ticket: LinkedTicket; className?: string }) { - const Icon = SYSTEM_ICON[ticket.system] - 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({ 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 = 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' - if (tickets.length === 1) { - const [ticket] = tickets - return ( - - - - ) - } - return ( - - - - - - {tickets.map((ticket) => ( - - - - {ticket.key} - {ticket.title} - - - - ))} - - - ) -} diff --git a/apps/sim/app/playground/org/components/mock-composer.tsx b/apps/sim/app/playground/org/components/mock-composer.tsx deleted file mode 100644 index 838b485565e..00000000000 --- a/apps/sim/app/playground/org/components/mock-composer.tsx +++ /dev/null @@ -1,50 +0,0 @@ -'use client' - -import { useState } from 'react' -import { ComposerActionButton, cn } from '@sim/emcn' -import { ArrowUp } from '@sim/emcn/icons' - -interface MockComposerProps { - placeholder: string - onSubmit?: (message: string) => void - rows?: number - className?: string -} - -/** Static stand-in for the chat composer: textarea plus send, no attachments or skills. */ -export function MockComposer({ placeholder, onSubmit, rows = 2, className }: MockComposerProps) { - const [value, setValue] = useState('') - const submit = () => { - const message = value.trim() - if (!message) return - onSubmit?.(message) - setValue('') - } - return ( -
    -