Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions apps/web/src/myra-channel.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
52 changes: 52 additions & 0 deletions apps/web/src/myra-channel.ts
Original file line number Diff line number Diff line change
@@ -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<EnsureMyraChannelResult> {
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),
};
}
}
163 changes: 57 additions & 106 deletions apps/web/src/pages/home-page.tsx
Original file line number Diff line number Diff line change
@@ -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 <Skeleton className="stat-skeleton" />;
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<string | null>(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 <BootScreen message="Opening Myra" />;
}
}

function workflowTileValue(query: APIQuery<RunsPage>): ReactNode {
if (query.kind === "ready") return purposeRuns(query.data.data).length;
return tileValue(query);
}
if (selectedTenantId === null) {
return (
<PageShell width="full" className="page-fill">
<EmptyState
icon={<CircleAlert />}
title="No workbench selected"
description="Pick a workbench from the switcher, then Myra will open here."
/>
</PageShell>
);
}

export function HomePage({
profile,
principals,
runs,
}: {
readonly profile: APIQuery<Profile>;
readonly principals: APIQuery<PrincipalsPage>;
readonly runs: APIQuery<RunsPage>;
}) {
const greeting =
profile.kind === "ready" ? `Welcome back, ${profile.data.name}` : "Welcome";
return (
<PageShell width="full" className="page-fill">
{profile.kind === "unauthenticated" ? (
<SignedOutNotice />
) : (
<>
<Section
title={greeting}
description="A live snapshot of your benches and what is running in them."
>
<StatGrid>
<StatTile label="Benches" value={tileValue(principals)} />
<StatTile
label="Active workflows"
value={workflowTileValue(runs)}
/>
</StatGrid>
</Section>
<Section
title="Jump in"
description="The main surfaces of this workbench."
>
<div className="card-grid">
{SHORTCUTS.map((shortcut) => (
<Link key={shortcut.to} to={shortcut.to} className="card-link">
<Card>
<CardHeader>
<CardTitle>{shortcut.title}</CardTitle>
<CardDescription>{shortcut.description}</CardDescription>
</CardHeader>
</Card>
</Link>
))}
</div>
</Section>
</>
)}
</PageShell>
);
}
if (error !== null) {
return (
<PageShell width="full" className="page-fill">
<EmptyState
icon={<CircleAlert />}
title="Couldn't open Myra"
description={error}
/>
</PageShell>
);
}

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 <HomePage profile={profile} principals={principals} runs={runs} />;
return <BootScreen message="Opening Myra" />;
}
2 changes: 1 addition & 1 deletion apps/web/src/pages/not-found-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export function NotFoundPage({ path }: { readonly path: string }) {
description={`Nothing lives at ${path}.`}
action={
<Button asChild variant="outline">
<Link to="/">Back to home</Link>
<Link to="/">Back to Myra</Link>
</Button>
}
/>
Expand Down
8 changes: 3 additions & 5 deletions apps/web/src/pages/onboarding-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ const GUIDANCE_CARDS = [

const ROUTINE_LABELS: Readonly<Record<string, string>> = {
echo: "Echo routine",
assistant: "Assistant routine",
assistant: "Myra routine",
};

function routineLabel(assetName: string): string {
Expand Down Expand Up @@ -310,7 +310,7 @@ export function OnboardingPage() {
>
<GuidanceCards />
<Button asChild>
<Link to="/c">Go to your starter channel</Link>
<Link to="/">Meet Myra</Link>
</Button>
</Section>
</PageShell>
Expand All @@ -332,9 +332,7 @@ export function OnboardingPage() {
>
<HorizontalStepper steps={wizardSteps(state.phase)} />
<ProgressChecklist steps={checklist} label="Default routines" />
<Button onClick={() => navigate("/c")}>
Open your starter channel
</Button>
<Button onClick={() => navigate("/")}>Meet Myra</Button>
</Section>
</PageShell>
);
Expand Down
17 changes: 10 additions & 7 deletions apps/web/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -74,7 +72,12 @@ export function matchesRoute(routePath: string, path: string): boolean {
}

export const APP_ROUTES: readonly AppRoute[] = [
{ path: "/", label: "Home", icon: <Home />, render: () => <HomeRoute /> },
{
path: "/",
label: "Myra",
icon: <MessageSquare />,
render: () => <HomeRoute />,
},
{
path: CHANNEL_PATH_PREFIX,
label: "Channels",
Expand Down Expand Up @@ -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),
);
2 changes: 1 addition & 1 deletion apps/web/src/shell/panel-contributions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<LiveActivityBand path={ctx.path} onNavigate={ctx.onNavigate} />
),
Expand Down
7 changes: 4 additions & 3 deletions apps/web/test/auth.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
Loading
Loading