diff --git a/apps/web/src/myra-channel.test.ts b/apps/web/src/myra-channel.test.ts new file mode 100644 index 000000000..22127e586 --- /dev/null +++ b/apps/web/src/myra-channel.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; + +import { + findMyraChannel, + isMyraChannelTitle, + MYRA_CHANNEL_TITLE, +} from "./myra-channel"; +import type { Channel } from "@corbits/chat-ui"; + +function channel(partial: { + readonly id: string; + readonly title: string; + readonly kind?: string; +}): Channel { + return { + id: partial.id, + title: partial.title, + kind: partial.kind ?? "channel", + pinned: false, + participants: [], + }; +} + +describe("myra-channel helpers", () => { + test("MYRA_CHANNEL_TITLE is Myra", () => { + expect(MYRA_CHANNEL_TITLE).toBe("Myra"); + }); + + test("isMyraChannelTitle is case-insensitive and trims", () => { + expect(isMyraChannelTitle("Myra")).toBe(true); + expect(isMyraChannelTitle(" myra ")).toBe(true); + expect(isMyraChannelTitle("MYRA")).toBe(true); + expect(isMyraChannelTitle("Myra chat")).toBe(false); + expect(isMyraChannelTitle("Assistant")).toBe(false); + }); + + test("findMyraChannel returns the first Myra-titled row", () => { + const items = [ + channel({ id: "a", title: "general" }), + channel({ id: "b", title: "myra" }), + channel({ id: "c", title: "Myra" }), + ]; + expect(findMyraChannel(items)?.id).toBe("b"); + }); + + test("findMyraChannel returns undefined when none match", () => { + expect( + findMyraChannel([channel({ id: "a", title: "general" })]), + ).toBeUndefined(); + }); +}); diff --git a/apps/web/src/myra-channel.ts b/apps/web/src/myra-channel.ts new file mode 100644 index 000000000..1d09e3c05 --- /dev/null +++ b/apps/web/src/myra-channel.ts @@ -0,0 +1,52 @@ +// Default Myra channel: the product land surface. Find an existing channel +// titled Myra (case-insensitive) or create one. Pure helpers stay free of +// React so they unit-test without a DOM. + +import { createChannel, listChannels, type Channel } from "@corbits/chat-ui"; + +export const MYRA_CHANNEL_TITLE = "Myra"; + +export type EnsureMyraChannelResult = + | { readonly kind: "ready"; readonly channelId: string } + | { readonly kind: "error"; readonly message: string }; + +export function isMyraChannelTitle(title: string): boolean { + return title.trim().toLowerCase() === MYRA_CHANNEL_TITLE.toLowerCase(); +} + +/** Prefer an exact Myra title; first match wins across the given list. */ +export function findMyraChannel( + channels: readonly Channel[], +): Channel | undefined { + return channels.find((channel) => isMyraChannelTitle(channel.title)); +} + +/** + * List channel + chat kinds, reuse a Myra-titled row if one exists, otherwise + * create a multiplayer channel named Myra. Full defineAgent-per-channel seed + * is CL-5656; this is the land path that opens canvas onto a real channel. + */ +export async function ensureMyraChannel( + tenantId: string, +): Promise { + try { + const [channels, chats] = await Promise.all([ + listChannels(tenantId, "channel"), + listChannels(tenantId, "chat"), + ]); + const existing = findMyraChannel(channels) ?? findMyraChannel(chats); + if (existing !== undefined) { + return { kind: "ready", channelId: existing.id }; + } + const created = await createChannel(tenantId, { + kind: "channel", + name: MYRA_CHANNEL_TITLE, + }); + return { kind: "ready", channelId: created.id }; + } catch (cause) { + return { + kind: "error", + message: cause instanceof Error ? cause.message : String(cause), + }; + } +} diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 4844f73bc..04802c9f1 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -1,114 +1,65 @@ -import { - Card, - CardDescription, - CardHeader, - CardTitle, - PageShell, - Section, - Skeleton, - StatGrid, - StatTile, -} from "@corbits/react-ui"; -import type { ReactNode } from "react"; +// Default land: open (or create) the Myra channel in the canvas. Home as a +// dashboard does not earn its keep — `/` only exists as the ensure+redirect +// hop. Deep links to other pages are unchanged. -import { useAPIQuery } from "../api"; -import { PrincipalsSchema, ProfileSchema, RunsSchema } from "../api"; -import type { APIQuery, PrincipalsPage, Profile, RunsPage } from "../api"; -import { Link } from "../navigation"; -import { purposeRuns } from "../purpose-runs"; -import { SignedOutNotice } from "../query-view"; +import { BootScreen, EmptyState, PageShell } from "@corbits/react-ui"; +import { CircleAlert } from "lucide-react"; +import { useEffect, useState } from "react"; -const SHORTCUTS = [ - { - to: "/c", - title: "Channels", - description: "Talk to an agent in a streaming conversation.", - }, - { - to: "/routines", - title: "Routines", - description: "Schedule a workflow, or launch one on demand.", - }, - { - to: "/library", - title: "Library", - description: - "Browse the documents, exports, and artifacts your workflows produce.", - }, -] as const; +import { useBench } from "../bench-context"; +import { channelPath } from "../channel-path"; +import { ensureMyraChannel } from "../myra-channel"; +import { useNavigate } from "../navigation"; -function tileValue(query: APIQuery<{ data: unknown[] }>): ReactNode { - switch (query.kind) { - case "loading": - return ; - case "ready": - return query.data.data.length; - case "unauthenticated": - case "error": - return "unavailable"; +export function HomeRoute() { + const navigate = useNavigate(); + const { selectedTenantId, memberships } = useBench(); + const [error, setError] = useState(null); + + useEffect(() => { + if (selectedTenantId === null) return; + let cancelled = false; + setError(null); + void ensureMyraChannel(selectedTenantId).then((result) => { + if (cancelled) return; + if (result.kind === "ready") { + navigate(channelPath(result.channelId)); + return; + } + setError(result.message); + }); + return () => { + cancelled = true; + }; + }, [selectedTenantId, navigate]); + + if (memberships.kind === "loading") { + return ; } -} -function workflowTileValue(query: APIQuery): ReactNode { - if (query.kind === "ready") return purposeRuns(query.data.data).length; - return tileValue(query); -} + if (selectedTenantId === null) { + return ( + + } + title="No workbench selected" + description="Pick a workbench from the switcher, then Myra will open here." + /> + + ); + } -export function HomePage({ - profile, - principals, - runs, -}: { - readonly profile: APIQuery; - readonly principals: APIQuery; - readonly runs: APIQuery; -}) { - const greeting = - profile.kind === "ready" ? `Welcome back, ${profile.data.name}` : "Welcome"; - return ( - - {profile.kind === "unauthenticated" ? ( - - ) : ( - <> -
- - - - -
-
-
- {SHORTCUTS.map((shortcut) => ( - - - - {shortcut.title} - {shortcut.description} - - - - ))} -
-
- - )} -
- ); -} + if (error !== null) { + return ( + + } + title="Couldn't open Myra" + description={error} + /> + + ); + } -export function HomeRoute() { - const profile = useAPIQuery("/api/me", ProfileSchema); - const principals = useAPIQuery("/api/me/principals", PrincipalsSchema); - const runs = useAPIQuery("/api/me/workflows/runs", RunsSchema); - return ; + return ; } diff --git a/apps/web/src/pages/not-found-page.tsx b/apps/web/src/pages/not-found-page.tsx index 198ea3398..dc30a316d 100644 --- a/apps/web/src/pages/not-found-page.tsx +++ b/apps/web/src/pages/not-found-page.tsx @@ -12,7 +12,7 @@ export function NotFoundPage({ path }: { readonly path: string }) { description={`Nothing lives at ${path}.`} action={ } /> diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index c96877ad9..5d6b4f528 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -65,7 +65,7 @@ const GUIDANCE_CARDS = [ const ROUTINE_LABELS: Readonly> = { echo: "Echo routine", - assistant: "Assistant routine", + assistant: "Myra routine", }; function routineLabel(assetName: string): string { @@ -310,7 +310,7 @@ export function OnboardingPage() { > @@ -332,9 +332,7 @@ export function OnboardingPage() { > - + ); diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index abc018ee0..7e4f24c98 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -4,14 +4,12 @@ // sidebar's identity dock, not the top nav — `NAV_ROUTES` is what the nav // list shows. Channel deep links (`/c/:channelId`) stay routable for the // main-pane fallback when the canvas column is not available; the rail no -// longer lists Chat. Approvals no longer has a page at all — the `/approvals` -// route is gone and its actionable cards live inline in the contextual -// panel's notifications band. +// longer lists Chat. Approvals has no page — the notifications band owns them. +// `/` is the Myra land hop (ensure + open channel), not a Home dashboard. import { Bot, ChartColumn, - Home, Library, MessageSquare, Settings, @@ -39,9 +37,9 @@ export const ONBOARDING_PATH = "/onboarding"; export const SETTINGS_PATH = "/settings"; /** Paths the rail lists — product nav; channels open in the canvas. + * Home is not a rail destination (Myra land is `/` only as a redirect hop). * Approvals has no route at all (notifications band owns its surface). */ const RAIL_NAV_PATHS = new Set([ - "/", "/routines", "/library", "/agents", @@ -74,7 +72,12 @@ export function matchesRoute(routePath: string, path: string): boolean { } export const APP_ROUTES: readonly AppRoute[] = [ - { path: "/", label: "Home", icon: , render: () => }, + { + path: "/", + label: "Myra", + icon: , + render: () => , + }, { path: CHANNEL_PATH_PREFIX, label: "Channels", @@ -125,7 +128,7 @@ export const APP_ROUTES: readonly AppRoute[] = [ /** What the rail lists: product pages only. Settings is the identity dock; * Channels stay deep-linkable but off the rail (canvas owns the surface). - * Approvals has no route. */ + * Approvals has no route. Home is not listed — land is Myra via `/`. */ export const NAV_ROUTES: readonly AppRoute[] = APP_ROUTES.filter((route) => RAIL_NAV_PATHS.has(route.path), ); diff --git a/apps/web/src/shell/panel-contributions.tsx b/apps/web/src/shell/panel-contributions.tsx index 9a71f3ca2..94f361b06 100644 --- a/apps/web/src/shell/panel-contributions.tsx +++ b/apps/web/src/shell/panel-contributions.tsx @@ -258,7 +258,7 @@ export function ensurePanelContributions(): void { registerPanelContribution({ id: "home", match: (path) => path === "/", - pageBand: defaultBand("Home", "Your workbench at a glance"), + pageBand: defaultBand("Myra", "Opening your default channel"), pageSpecific: (ctx) => ( ), diff --git a/apps/web/test/auth.test.tsx b/apps/web/test/auth.test.tsx index 2d1c75fa0..f9efd9cbc 100644 --- a/apps/web/test/auth.test.tsx +++ b/apps/web/test/auth.test.tsx @@ -146,9 +146,10 @@ describe("the gate", () => { const markup = renderApp({ kind: "signed-in", user }); expect(markup).toContain("Sign out"); expect(markup).toContain("ada@example.com"); - expect(markup).toMatch( - /data-slot="sidebar-rail-item"[^>]*aria-current="page"/, - ); + // Default land is the Myra channel canvas — no rail destination is current + // (channel paths are not rail items). Assert the rail and shell still mount. + expect(markup).toContain('data-slot="sidebar-rail"'); + expect(markup).toContain('data-slot="sidebar-rail-item"'); }); test("loading and error are their own screens, not a broken shell", () => { diff --git a/apps/web/test/pages.test.tsx b/apps/web/test/pages.test.tsx index f8c357e47..140322ccf 100644 --- a/apps/web/test/pages.test.tsx +++ b/apps/web/test/pages.test.tsx @@ -14,7 +14,6 @@ import type { AgentInstance, } from "../src/agents-api"; import { AgentsPage } from "../src/pages/agents-page"; -import { HomePage } from "../src/pages/home-page"; import { LibraryPage } from "../src/pages/library-page"; import { SkillsPage } from "../src/pages/skills-page"; @@ -22,18 +21,7 @@ function ready(data: T): APIQuery { return { kind: "ready", data }; } -const emptyPage = ready({ data: [], nextCursor: null }); const unauthenticated = { kind: "unauthenticated" } as const; -const profile = ready({ - id: "user_1", - name: "Ada", - email: "ada@example.com", - emailVerified: true, - createdAt: "2026-01-01T00:00:00.000Z", - updatedAt: "2026-01-01T00:00:00.000Z", - image: null, -}); - describe("empty states", () => { test("library teaches what will appear once the seam is real", () => { const markup = renderToStaticMarkup(); @@ -58,19 +46,6 @@ describe("empty states", () => { }); }); -describe("signed-out state", () => { - test("home reports a missing session", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Sign in required"); - }); -}); - describe("live data", () => { const reportArtifact: ArtifactSummary = { id: "art_1", @@ -195,29 +170,4 @@ describe("live data", () => { // Dialog must not mount without a real tenant — no create form markup. expect(markup).not.toContain("Define a new agent"); }); - - test("home counts what the hub reports", () => { - const markup = renderToStaticMarkup( - , - ); - expect(markup).toContain("Welcome back, Ada"); - expect(markup).toContain("Benches"); - }); }); diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index f43eda87e..6f6466d0b 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -78,9 +78,8 @@ describe("route table", () => { ]); }); - test("rail nav is Home, Routines, Library, Agents, Skills, Insights", () => { + test("rail nav is Routines, Library, Agents, Skills, Insights (no Home)", () => { expect(NAV_ROUTES.map((route) => route.label)).toEqual([ - "Home", "Routines", "Library", "Agents", diff --git a/apps/web/test/shell-contextual-panel.test.tsx b/apps/web/test/shell-contextual-panel.test.tsx index 521db4b84..82bb868e9 100644 --- a/apps/web/test/shell-contextual-panel.test.tsx +++ b/apps/web/test/shell-contextual-panel.test.tsx @@ -93,7 +93,6 @@ describe("Rail bench switcher", () => { stubMemberships(); const el = await renderRail(); for (const label of [ - "Home", "Routines", "Library", "Agents", @@ -102,6 +101,7 @@ describe("Rail bench switcher", () => { ]) { expect(el.textContent).toContain(label); } + expect(el.textContent).not.toContain("Home"); expect(el.textContent).not.toContain("Chat"); expect(el.textContent).not.toContain("Approvals"); }); diff --git a/packages/workflow-catalog/src/index.ts b/packages/workflow-catalog/src/index.ts index 8965e6b9b..9a639ac10 100644 --- a/packages/workflow-catalog/src/index.ts +++ b/packages/workflow-catalog/src/index.ts @@ -26,7 +26,7 @@ export const WORKFLOW_CATALOG: readonly WorkflowCatalogEntry[] = [ }, { assetName: "assistant", - displayName: "Assistant", + displayName: "Myra", automatable: false, }, { diff --git a/packages/workflow-catalog/test/catalog.test.ts b/packages/workflow-catalog/test/catalog.test.ts index a97179d46..decfc4d24 100644 --- a/packages/workflow-catalog/test/catalog.test.ts +++ b/packages/workflow-catalog/test/catalog.test.ts @@ -24,6 +24,7 @@ describe("workflow catalog", () => { expect(workflowDisplayName("channel-digest")).toBe("Channel digest"); expect(workflowDisplayName("heartbeat")).toBe("Heartbeat"); expect(workflowDisplayName("echo")).toBe("Echo"); + expect(workflowDisplayName("assistant")).toBe("Myra"); }); test("falls back to description, then humanized name — never blank", () => {