diff --git a/apps/mesh/public/demo/vo/vo-01.m4a b/apps/mesh/public/demo/vo/vo-01.m4a new file mode 100644 index 0000000000..bfe5a96a38 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-01.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-02.m4a b/apps/mesh/public/demo/vo/vo-02.m4a new file mode 100644 index 0000000000..fe3d74bb80 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-02.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-03.m4a b/apps/mesh/public/demo/vo/vo-03.m4a new file mode 100644 index 0000000000..649cb7c253 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-03.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-04.m4a b/apps/mesh/public/demo/vo/vo-04.m4a new file mode 100644 index 0000000000..662ee31f16 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-04.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-05.m4a b/apps/mesh/public/demo/vo/vo-05.m4a new file mode 100644 index 0000000000..0d49d3a3bd Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-05.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-06.m4a b/apps/mesh/public/demo/vo/vo-06.m4a new file mode 100644 index 0000000000..e0f5e91267 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-06.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-07.m4a b/apps/mesh/public/demo/vo/vo-07.m4a new file mode 100644 index 0000000000..e4c764f874 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-07.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-08.m4a b/apps/mesh/public/demo/vo/vo-08.m4a new file mode 100644 index 0000000000..a50f11cc4c Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-08.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-09.m4a b/apps/mesh/public/demo/vo/vo-09.m4a new file mode 100644 index 0000000000..83cc287f60 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-09.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-10.m4a b/apps/mesh/public/demo/vo/vo-10.m4a new file mode 100644 index 0000000000..917a2e13fc Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-10.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-11.m4a b/apps/mesh/public/demo/vo/vo-11.m4a new file mode 100644 index 0000000000..8e3422b672 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-11.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-12.m4a b/apps/mesh/public/demo/vo/vo-12.m4a new file mode 100644 index 0000000000..d0614b5331 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-12.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-13.m4a b/apps/mesh/public/demo/vo/vo-13.m4a new file mode 100644 index 0000000000..9b7dd486c8 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-13.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-14.m4a b/apps/mesh/public/demo/vo/vo-14.m4a new file mode 100644 index 0000000000..fe00bc5539 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-14.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-15.m4a b/apps/mesh/public/demo/vo/vo-15.m4a new file mode 100644 index 0000000000..bd6590ab5d Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-15.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-16.m4a b/apps/mesh/public/demo/vo/vo-16.m4a new file mode 100644 index 0000000000..18f96311ab Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-16.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-17.m4a b/apps/mesh/public/demo/vo/vo-17.m4a new file mode 100644 index 0000000000..7e232b091c Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-17.m4a differ diff --git a/apps/mesh/public/demo/vo/vo-18.m4a b/apps/mesh/public/demo/vo/vo-18.m4a new file mode 100644 index 0000000000..b9b6813f71 Binary files /dev/null and b/apps/mesh/public/demo/vo/vo-18.m4a differ diff --git a/apps/mesh/src/web/components/account-popover.tsx b/apps/mesh/src/web/components/account-popover.tsx index 13f496dad0..01703eb15c 100644 --- a/apps/mesh/src/web/components/account-popover.tsx +++ b/apps/mesh/src/web/components/account-popover.tsx @@ -20,7 +20,6 @@ import { import { cn } from "@deco/ui/lib/utils.ts"; import { useIsMobile } from "@deco/ui/hooks/use-mobile.ts"; import { - Check, Copy01, Download01, File06, @@ -28,70 +27,27 @@ import { LogOut01, Monitor01, Moon01, - Plus, - SearchMd, Settings02, Shield01, Sun, Users03, VolumeMax, VolumeX, - XClose, } from "@untitledui/icons"; import { GitHubIcon } from "@daveyplate/better-auth-ui"; import { SidebarMenuButton } from "@deco/ui/components/sidebar.tsx"; -import { authClient, useActiveOrganizations } from "@/web/lib/auth-client"; +import { authClient } from "@/web/lib/auth-client"; import { useProjectContext } from "@decocms/mesh-sdk"; import { track } from "@/web/lib/posthog-client"; import { clearPersistedQueryCache } from "@/web/lib/query-persist"; import { CreateOrganizationDialog } from "@/web/components/create-organization-dialog"; +import { + getOrgColorStyle, + OrganizationsPanel, +} from "@/web/components/header/org-switcher"; import { usePreferences, type ThemeMode } from "@/web/hooks/use-preferences.ts"; import { toast } from "@deco/ui/components/sonner.js"; -function getOrgColorStyle(name: string): { - backgroundColor: string; - color: string; -} { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = name.charCodeAt(i) + ((hash << 5) - hash); - } - const h = Math.abs(hash) % 360; - return { - backgroundColor: `hsl(${h} 55% 70%)`, - color: `hsl(${h} 55% 20%)`, - }; -} - -function OrgIcon({ - org, - size = "sm", -}: { - org: { name: string; logo?: string | null }; - size?: "xs" | "sm"; -}) { - const sizeClass = size === "xs" ? "size-5" : "size-6"; - const textClass = size === "xs" ? "text-[9px]" : "text-xs"; - - return ( -
- {org.logo ? ( - - ) : ( - - {org.name.slice(0, 2).toUpperCase()} - - )} -
- ); -} - interface MenuItem { key: string; label: string; @@ -141,112 +97,6 @@ function MenuItemButton({ ); } -function OrganizationsPanel({ - orgParam, - onSelectOrg, - onCreateOrg, -}: { - orgParam?: string; - onSelectOrg: (slug: string) => void; - onCreateOrg: () => void; -}) { - // Fetched here, not in the parent: this panel only mounts inside the open - // popover/drawer, so the (potentially large) organization.list call is - // deferred until the switcher is actually opened — it no longer fires on - // every page load. - const { data: organizations } = useActiveOrganizations(); - const sortedOrgs = [...(organizations ?? [])].sort((a, b) => { - if (a.slug === orgParam) return -1; - if (b.slug === orgParam) return 1; - return a.name.localeCompare(b.name); - }); - - const [searchOpen, setSearchOpen] = useState(false); - const [query, setQuery] = useState(""); - - const q = query.toLowerCase(); - const filtered = q - ? sortedOrgs.filter( - (o) => - o.name.toLowerCase().includes(q) || o.slug.toLowerCase().includes(q), - ) - : sortedOrgs; - - const iconBtnClass = - "flex items-center justify-center size-7 rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"; - - function toggleSearch() { - if (searchOpen) setQuery(""); - setSearchOpen((prev) => !prev); - } - - return ( - <> -
- {searchOpen ? ( - setQuery(e.target.value)} - onKeyDown={(e) => e.key === "Escape" && toggleSearch()} - placeholder="Search organizations..." - className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" - /> - ) : ( - - Your Organizations - - )} -
- - -
-
-
- {filtered.length === 0 && ( -

- {query - ? `No organizations match "${query}"` - : "No organizations available"} -

- )} - {filtered.map((org) => ( - - ))} -
- - ); -} - /** Shared content rendered inside popover (desktop) or drawer (mobile) */ function AccountPopoverContent({ user, diff --git a/apps/mesh/src/web/components/chat/chat-context.tsx b/apps/mesh/src/web/components/chat/chat-context.tsx index eb49e5ea71..339de8c924 100644 --- a/apps/mesh/src/web/components/chat/chat-context.tsx +++ b/apps/mesh/src/web/components/chat/chat-context.tsx @@ -370,6 +370,13 @@ interface TaskProviderInternals { // ============================================================================ const ChatStreamCtx = createContext(null); +/** + * Exposed so Demo Mode (`web/demo/`) can supply a scripted `ChatStreamContextValue` + * directly — feeding the real chat renderers a recorded stream without the live + * `ActiveTaskProvider` (MCP transport, SSE, etc.). Not for app use; prefer the + * hooks. See `web/demo/demo-chat-stream.tsx`. + */ +export { ChatStreamCtx as DemoChatStreamContext }; const ChatTaskCtx = createContext(null); const ChatPrefsCtx = createContext(null); diff --git a/apps/mesh/src/web/components/chat/message/assistant.tsx b/apps/mesh/src/web/components/chat/message/assistant.tsx index 5cf152fe53..411c4f8921 100644 --- a/apps/mesh/src/web/components/chat/message/assistant.tsx +++ b/apps/mesh/src/web/components/chat/message/assistant.tsx @@ -10,6 +10,7 @@ import { import type { ToolUIPart } from "ai"; import { type ReactNode, Suspense, useEffect, useState } from "react"; import { ToolCallShell } from "./parts/tool-call-part/common.tsx"; +import { getPartRenderer as getExtraPartRenderer } from "./parts/extra-part-renderers.ts"; import type { ChatMessage } from "../types.ts"; import { MessageStatsBar } from "../usage-stats.tsx"; import { MessageTextPart } from "./parts/text-part.tsx"; @@ -517,6 +518,11 @@ function MessagePart({ const getSubtaskMeta = (toolCallId: string) => dataParts.toolSubtaskMetadata.get(toolCallId); + // Custom part renderers (e.g. Demo Mode) take precedence over the built-in + // switch. Empty registry in normal use — see extra-part-renderers.ts. + const extraRenderer = getExtraPartRenderer(part.type); + if (extraRenderer) return <>{extraRenderer(part as { type: string })}; + switch (part.type) { case "dynamic-tool": return ( diff --git a/apps/mesh/src/web/components/chat/message/parts/extra-part-renderers.ts b/apps/mesh/src/web/components/chat/message/parts/extra-part-renderers.ts new file mode 100644 index 0000000000..986387cc2f --- /dev/null +++ b/apps/mesh/src/web/components/chat/message/parts/extra-part-renderers.ts @@ -0,0 +1,21 @@ +/** + * Tiny extension point for rendering custom message parts inline in the chat. + * + * The core renderer (`message/assistant.tsx`) consults this registry before its + * built-in switch, so non-core surfaces (e.g. Demo Mode) can render their own + * part types in the real chat flow WITHOUT the core importing them. The registry + * is empty in normal use; a consumer registers a renderer at module load. + */ +import type { ReactNode } from "react"; + +type PartRenderer = (part: { type: string; output?: unknown }) => ReactNode; + +const registry = new Map(); + +export function registerPartRenderer(type: string, render: PartRenderer): void { + registry.set(type, render); +} + +export function getPartRenderer(type: string): PartRenderer | undefined { + return registry.get(type); +} diff --git a/apps/mesh/src/web/components/header/org-switcher.tsx b/apps/mesh/src/web/components/header/org-switcher.tsx new file mode 100644 index 0000000000..4b6d7c7a70 --- /dev/null +++ b/apps/mesh/src/web/components/header/org-switcher.tsx @@ -0,0 +1,223 @@ +/** + * Org switcher — shared building blocks for switching the active organization. + * + * Extracted from `account-popover.tsx` so the same list UI backs both the + * sidebar-footer account popover and the toolbar breadcrumb (see + * `shell-breadcrumb.tsx`). The org list is fetched lazily inside + * `OrganizationsPanel`, so mounting a closed switcher costs nothing. + */ +import { type ReactNode, useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@deco/ui/components/popover.tsx"; +import { Check, Plus, SearchMd, XClose } from "@untitledui/icons"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { useActiveOrganizations } from "@/web/lib/auth-client"; +import { CreateOrganizationDialog } from "@/web/components/create-organization-dialog"; + +export function getOrgColorStyle(name: string): { + backgroundColor: string; + color: string; +} { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + const h = Math.abs(hash) % 360; + return { + backgroundColor: `hsl(${h} 55% 70%)`, + color: `hsl(${h} 55% 20%)`, + }; +} + +export function OrgIcon({ + org, + size = "sm", +}: { + org: { name: string; logo?: string | null }; + size?: "xs" | "sm"; +}) { + const sizeClass = size === "xs" ? "size-5" : "size-6"; + const textClass = size === "xs" ? "text-[9px]" : "text-xs"; + + return ( +
+ {org.logo ? ( + + ) : ( + + {org.name.slice(0, 2).toUpperCase()} + + )} +
+ ); +} + +export function OrganizationsPanel({ + orgParam, + onSelectOrg, + onCreateOrg, +}: { + orgParam?: string; + onSelectOrg: (slug: string) => void; + onCreateOrg: () => void; +}) { + // Fetched here, not in the parent: this panel only mounts inside the open + // popover/drawer, so the (potentially large) organization.list call is + // deferred until the switcher is actually opened — it no longer fires on + // every page load. + const { data: organizations } = useActiveOrganizations(); + const sortedOrgs = [...(organizations ?? [])].sort((a, b) => { + if (a.slug === orgParam) return -1; + if (b.slug === orgParam) return 1; + return a.name.localeCompare(b.name); + }); + + const [searchOpen, setSearchOpen] = useState(false); + const [query, setQuery] = useState(""); + + const q = query.toLowerCase(); + const filtered = q + ? sortedOrgs.filter( + (o) => + o.name.toLowerCase().includes(q) || o.slug.toLowerCase().includes(q), + ) + : sortedOrgs; + + const iconBtnClass = + "flex items-center justify-center size-7 rounded-md text-muted-foreground hover:bg-accent/50 hover:text-foreground transition-colors"; + + function toggleSearch() { + if (searchOpen) setQuery(""); + setSearchOpen((prev) => !prev); + } + + return ( + <> +
+ {searchOpen ? ( + setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Escape" && toggleSearch()} + placeholder="Search organizations..." + className="flex-1 min-w-0 bg-transparent text-sm outline-none placeholder:text-muted-foreground/60" + /> + ) : ( + + Your Organizations + + )} +
+ + +
+
+
+ {filtered.length === 0 && ( +

+ {query + ? `No organizations match "${query}"` + : "No organizations available"} +

+ )} + {filtered.map((org) => ( + + ))} +
+ + ); +} + +/** + * Self-contained org switcher popover: renders `trigger` and, on open, the + * organization list. Selecting an org navigates to `/$org`; the "+" affordance + * opens the create-organization dialog. Used by the toolbar breadcrumb. + */ +export function OrgSwitcherPopover({ + trigger, + orgParam, + align = "start", + side = "bottom", +}: { + trigger: ReactNode; + orgParam?: string; + align?: "start" | "center" | "end"; + side?: "top" | "right" | "bottom" | "left"; +}) { + const navigate = useNavigate(); + const [open, setOpen] = useState(false); + const [creatingOrg, setCreatingOrg] = useState(false); + + return ( + <> + + {trigger} + e.preventDefault()} + > + { + setOpen(false); + navigate({ to: "/$org", params: { org: slug } }); + }} + onCreateOrg={() => { + setOpen(false); + setCreatingOrg(true); + }} + /> + + + + + ); +} diff --git a/apps/mesh/src/web/components/header/shell-breadcrumb.tsx b/apps/mesh/src/web/components/header/shell-breadcrumb.tsx new file mode 100644 index 0000000000..26cae2fb50 --- /dev/null +++ b/apps/mesh/src/web/components/header/shell-breadcrumb.tsx @@ -0,0 +1,114 @@ +/** + * Shell breadcrumb — primary org navigation in the toolbar: `deco › org › agent`. + * + * - **deco** — the product logo, links to `/` (the cross-org "MY deco" home). + * - **org** — the active organization; clicking opens the org switcher popover. + * - **agent**— the current agent, shown only inside an agent/thread route. + * + * This replaces the sidebar-footer popover as the place you switch orgs (the + * footer popover now carries account/settings only). Renders inside + * `Toolbar.LeftColumn` — see `org-shell-layout`. + */ +import { Suspense } from "react"; +import { Link, useParams, useSearch } from "@tanstack/react-router"; +import { ChevronDown } from "@untitledui/icons"; +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbSeparator, +} from "@deco/ui/components/breadcrumb.tsx"; +import { useProjectContext, useVirtualMCP } from "@decocms/mesh-sdk"; +import { Toolbar } from "@/web/layouts/agent-shell-layout/toolbar"; +import { AgentAvatar } from "@/web/components/agent-icon"; +import { + OrgIcon, + OrgSwitcherPopover, +} from "@/web/components/header/org-switcher"; + +const crumbBtnClass = + "wco-no-drag inline-flex items-center gap-1.5 min-w-0 rounded-md px-1.5 py-1 text-sm text-foreground hover:bg-accent/60 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"; + +/** + * Trailing agent crumb — resolves the agent entity from `?virtualmcpid` (the + * same source the agent shell reads). Suspends on the collection fetch, so it's + * wrapped in its own boundary and never blocks the rest of the toolbar. + */ +function AgentCrumb() { + const search = useSearch({ strict: false }) as { virtualmcpid?: string }; + const entity = useVirtualMCP(search.virtualmcpid ?? null); + // No explicit agent selected (default decopilot) → no crumb; the breadcrumb + // stays `deco › org` on a bare chat. + if (!entity) return null; + const title = entity.title ?? "Agent"; + return ( + <> + + + + + {title} + + + + ); +} + +export function ShellBreadcrumb() { + const { org } = useProjectContext(); + const params = useParams({ strict: false }) as { + org?: string; + taskId?: string; + }; + const isAgentRoute = Boolean(params.taskId); + + return ( + + + {/* deco → MY deco home */} + + + + + + + + + {/* org → switcher popover */} + + + + + {org.name} + + + + } + /> + + + {/* agent → only inside a thread */} + {isAgentRoute && ( + + + + )} + + + ); +} diff --git a/apps/mesh/src/web/demo/chrome.tsx b/apps/mesh/src/web/demo/chrome.tsx new file mode 100644 index 0000000000..b22df235e9 --- /dev/null +++ b/apps/mesh/src/web/demo/chrome.tsx @@ -0,0 +1,77 @@ +/** + * Demo Mode — shared chrome for scenario stages. + * + * Lightweight product-like framing (preview pane) around the REAL chat. + * Pure presentational; all live content comes from the Director's stores. + */ + +/** One selectable MCP-app tab in the preview chrome (top right). */ +export interface PreviewApp { + label: string; + active: boolean; + onClick?: () => void; +} + +/** A browser-like preview pane that renders demo HTML in an isolated iframe. + * `apps` renders the agent's MCP-app switcher on the toolbar's right — the + * current screen is explicitly ONE app UI of this agent among many, and the + * agent decides which apps it offers. */ +export function PreviewFrame({ + url, + html, + apps, +}: { + url: string; + html: string; + apps?: PreviewApp[]; +}) { + return ( +
+
+ + + + + +
+ {url} +
+ {apps && ( +
+ {apps.map((app) => ( + + ))} +
+ )} +
+
+ {html ? ( + // key on html → each preview change fades in (masks the srcDoc swap) +