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
18 changes: 9 additions & 9 deletions apps/web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,15 +48,15 @@ in a real `@corbits/routines` listing later touches nothing else.

## Screens

| Path | What it shows |
| ------------ | --------------------------------------------------------------------------------- |
| `/` | Home — a welcome summary of the signed-in account's benches and runs. |
| `/chat` | The chat surface (`@corbits/chat-ui`): channels, direct chats, and threads. |
| `/workflows` | Workflow runs executing across your benches. |
| `/library` | The artifact gallery. See "Library" below. |
| `/agents` | Agent definitions you can invite into a channel, and each channel's participants. |
| `/skills` | A stub: skills have no registry in the hub yet, so this describes what's coming. |
| `/settings` | Account and bench membership settings. |
| Path | What it shows |
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/` | Home — a welcome summary of the signed-in account's benches and runs. |
| `/c` | Channel deep-link surface. On wide layouts the conversation opens in the right canvas; on compact layouts it fills the main pane. Legacy `/chat` links still resolve here. |
| `/workflows` | Workflow runs executing across your benches. |
| `/library` | The artifact gallery. See "Library" below. |
| `/agents` | Agent definitions you can invite into a channel, and each channel's participants. |
| `/skills` | A stub: skills have no registry in the hub yet, so this describes what's coming. |
| `/settings` | Account and bench membership settings. |

Approvals are not a page: pending permission requests land as actionable
cards in the contextual panel's Notifications band (and, when a channel is
Expand Down
34 changes: 34 additions & 0 deletions apps/web/src/channel-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Channel deep links live at `/c/:channelId`. The retired `/chat` prefix
// still resolves here so old bookmarks and in-flight links land on the
// same surface instead of a dead route.

export const CHANNEL_PATH_PREFIX = "/c";
const LEGACY_CHAT_PATH_PREFIX = "/chat";

/** Extract a channel id from `/c/:id` or the legacy `/chat/:id`. */
export function channelIdFromPath(path: string): string | null {
for (const prefix of [CHANNEL_PATH_PREFIX, LEGACY_CHAT_PATH_PREFIX]) {
if (path === prefix) return null;
if (!path.startsWith(`${prefix}/`)) continue;
const rest = path.slice(prefix.length + 1);
if (rest === "") return null;
return decodeURIComponent(rest);
}
return null;
}

/** True for `/c`, `/c/:id`, and the legacy `/chat` equivalents. */
export function isChannelPath(path: string): boolean {
return (
path === CHANNEL_PATH_PREFIX ||
path.startsWith(`${CHANNEL_PATH_PREFIX}/`) ||
path === LEGACY_CHAT_PATH_PREFIX ||
path.startsWith(`${LEGACY_CHAT_PATH_PREFIX}/`)
);
}

/** Canonical path for a channel (or the empty channel surface). */
export function channelPath(channelId: string | null): string {
if (channelId === null) return CHANNEL_PATH_PREFIX;
return `${CHANNEL_PATH_PREFIX}/${encodeURIComponent(channelId)}`;
}
2 changes: 1 addition & 1 deletion apps/web/src/command-palette-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export function CommandPaletteProvider({
if (id.startsWith("route:")) {
navigate(id.slice("route:".length));
} else if (id.startsWith("entity:channels:")) {
navigate(`/chat/${id.slice("entity:channels:".length)}`);
navigate(`/c/${id.slice("entity:channels:".length)}`);
} else if (id.startsWith("entity:runs:")) {
// Routines page owns the /routines prefix (including detail segments).
navigate(`/routines/${id.slice("entity:runs:".length)}`);
Expand Down
7 changes: 4 additions & 3 deletions apps/web/src/pages/agents-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import type { AgentDirectoryData } from "../agents-api";
import type { APIQuery } from "../api";
import { useAgentDirectory } from "../agents-api";
import { useBench } from "../bench-context";
import { channelPath } from "../channel-path";
import { tenantKeys } from "../query-client";
import { QueryView } from "../query-view";
import { CreateAgentDialog } from "./create-agent-dialog";
Expand Down Expand Up @@ -260,7 +261,7 @@ function AgentDetailPanel({
kind: "chat",
definitionId: definition.id,
});
const target = `/chat/${encodeURIComponent(channel.id)}`;
const target = channelPath(channel.id);
onChatStarted(channel.id);
navigate?.(target);
} catch (cause) {
Expand All @@ -271,7 +272,7 @@ function AgentDetailPanel({
}

function handleOpenInChannel() {
navigate?.("/chat");
navigate?.(channelPath(null));
}

return (
Expand Down Expand Up @@ -461,7 +462,7 @@ export function AgentsPage({
* injectable for tests that need to assert detail markup without a click. */
readonly initialSelectedDefinitionId?: string;
/** Client-side navigation callback; Start chat and Open in channel rely
* on this to route into /chat after creating/inviting. */
* on this to route into /c after creating/inviting. */
readonly navigate?: (to: string) => void;
}) {
const [query, setQuery] = useState("");
Expand Down
46 changes: 29 additions & 17 deletions apps/web/src/pages/chat-page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,19 @@
// Adapts this app's bench selection (see ../bench-context.tsx) into
// `@corbits/chat-ui`'s `TenantResolution`. The chat surface itself is
// entirely `@corbits/chat-ui`'s — this file resolves which bench it talks
// to and mirrors the active channel into the URL as /chat/:channelId so
// conversations are linkable.
// Channel surface for the main pane. On an expanded layout the canvas
// column hosts the same `ChatWorkspace` and this page is a short pointer
// so the main pane isn't empty under a deep link. On compact/narrow the
// canvas is gone, so this page is the full conversation surface.
//
// Deep links use `/c/:channelId`; the legacy `/chat/:channelId` prefix is
// still parsed so old links keep working.

import { ChatWorkspace } from "@corbits/chat-ui";
import type { TenantResolution } from "@corbits/chat-ui";
import { EmptyState } from "@corbits/react-ui";
import { MessageSquare } from "lucide-react";

import { useBench } from "../bench-context";

const CHAT_PATH_PREFIX = "/chat";

function channelIdFromPath(path: string): string | null {
if (!path.startsWith(`${CHAT_PATH_PREFIX}/`)) return null;
const rest = path.slice(CHAT_PATH_PREFIX.length + 1);
return rest === "" ? null : decodeURIComponent(rest);
}
import { channelIdFromPath, channelPath } from "../channel-path";
import { useCanvasColumnAvailable } from "../shell/canvas-availability";

export function ChatPage({
path,
Expand All @@ -24,7 +22,23 @@ export function ChatPage({
readonly path: string;
readonly navigate: (to: string) => void;
}) {
const canvasAvailable = useCanvasColumnAvailable();
const { memberships, selectedTenantId, selectedPrincipalId } = useBench();
const channelId = channelIdFromPath(path);

if (canvasAvailable) {
return (
<EmptyState
icon={<MessageSquare />}
title={channelId === null ? "Channels" : "Channel open"}
description={
channelId === null
? "Pick a channel from the panel — the conversation opens in the canvas on the right."
: "The conversation is open in the canvas on the right. Close the canvas to free the space, or pick another channel from the panel."
}
/>
);
}

let tenant: TenantResolution;
if (memberships.kind !== "ready") {
Expand All @@ -41,10 +55,8 @@ export function ChatPage({
<ChatWorkspace
tenant={tenant}
{...(principalId !== undefined ? { currentUser: { principalId } } : {})}
channelId={channelIdFromPath(path)}
onChannelChange={(channelId) =>
navigate(`${CHAT_PATH_PREFIX}/${encodeURIComponent(channelId)}`)
}
channelId={channelId}
onChannelChange={(nextChannelId) => navigate(channelPath(nextChannelId))}
/>
);
}
4 changes: 2 additions & 2 deletions apps/web/src/pages/home-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ import { SignedOutNotice } from "../query-view";

const SHORTCUTS = [
{
to: "/chat",
title: "Chat",
to: "/c",
title: "Channels",
description: "Talk to an agent in a streaming conversation.",
},
{
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/pages/onboarding-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ export function OnboardingPage() {
>
<GuidanceCards />
<Button asChild>
<Link to="/chat">Go to your starter channel</Link>
<Link to="/c">Go to your starter channel</Link>
</Button>
</Section>
</PageShell>
Expand All @@ -332,7 +332,7 @@ export function OnboardingPage() {
>
<HorizontalStepper steps={wizardSteps(state.phase)} />
<ProgressChecklist steps={checklist} label="Default routines" />
<Button onClick={() => navigate("/chat")}>
<Button onClick={() => navigate("/c")}>
Open your starter channel
</Button>
</Section>
Expand Down
30 changes: 16 additions & 14 deletions apps/web/src/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@
// icon) and the route switch (render), so navigation and pages cannot drift
// apart. Settings renders like any other route but is reached from the
// sidebar's identity dock, not the top nav — `NAV_ROUTES` is what the nav
// list shows. Chat stays routable for deep links but leaves the rail (the
// channel surface owns its next home). 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.
// 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.

import {
Bot,
Expand All @@ -19,6 +20,7 @@ import {
} from "lucide-react";
import type { ReactElement, ReactNode } from "react";

import { CHANNEL_PATH_PREFIX, isChannelPath } from "./channel-path";
import { AgentsRoute } from "./pages/agents-page";
import { ChatPage } from "./pages/chat-page";
import { HomeRoute } from "./pages/home-page";
Expand All @@ -36,8 +38,8 @@ export const ONBOARDING_PATH = "/onboarding";
/** Settings lives in the sidebar's identity dock, not the top nav. */
export const SETTINGS_PATH = "/settings";

/** Paths the rail lists — product nav after Chat and Approvals leave the rail.
* Approvals now has no route at all (notifications band owns its surface). */
/** Paths the rail lists — product nav; channels open in the canvas.
* Approvals has no route at all (notifications band owns its surface). */
const RAIL_NAV_PATHS = new Set([
"/",
"/routines",
Expand All @@ -58,13 +60,12 @@ export type AppRoute = {
};

/**
* Matches /chat and /chat/:channelId — the channel id segment is the
* chat page's own concern; the shell only needs to know the page owns
* the whole /chat prefix.
* Matches `/c` and `/c/:channelId` (plus the legacy `/chat` prefix), and
* `/routines` / `/routines/:id`. Other routes are exact path matches.
*/
export function matchesRoute(routePath: string, path: string): boolean {
if (routePath === "/chat") {
return path === "/chat" || path.startsWith("/chat/");
if (routePath === CHANNEL_PATH_PREFIX) {
return isChannelPath(path);
}
if (routePath === "/routines") {
return path === "/routines" || path.startsWith("/routines/");
Expand All @@ -75,8 +76,8 @@ export function matchesRoute(routePath: string, path: string): boolean {
export const APP_ROUTES: readonly AppRoute[] = [
{ path: "/", label: "Home", icon: <Home />, render: () => <HomeRoute /> },
{
path: "/chat",
label: "Chat",
path: CHANNEL_PATH_PREFIX,
label: "Channels",
icon: <MessageSquare />,
render: (path: string, navigate: (to: string) => void) => (
<ChatPage path={path} navigate={navigate} />
Expand Down Expand Up @@ -123,7 +124,8 @@ export const APP_ROUTES: readonly AppRoute[] = [
];

/** What the rail lists: product pages only. Settings is the identity dock;
* Chat stays deep-linkable but off the rail. Approvals has no route. */
* Channels stay deep-linkable but off the rail (canvas owns the surface).
* Approvals has no route. */
export const NAV_ROUTES: readonly AppRoute[] = APP_ROUTES.filter((route) =>
RAIL_NAV_PATHS.has(route.path),
);
69 changes: 49 additions & 20 deletions apps/web/src/shell/app-shell.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,25 @@
// The four-column app shell: the global rail, the contextual panel, the
// main pane a route renders into, and the optional canvas. Every route in
// `../routes.tsx` mounts inside this same frame — there is no per-route
// shell variant. The canvas toggle lives in the panel page band, never as
// an absolute overlay over page actions.
// shell variant. The canvas hosts the channel chat surface; its toggle
// lives in the panel page band, never as an absolute overlay over page
// actions. Deep links (`/c/:channelId`) open the canvas onto that channel.

import { useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type ReactNode } from "react";

import { channelIdFromPath, channelPath, isChannelPath } from "../channel-path";
import { useNavigate } from "../navigation";
import type { SessionUser } from "../session";
import { canvasColumnAllowed, contextualPanelVisible } from "./breakpoints";
import { useShellFocusRescue } from "./focus-rescue";
import { useScrollReset } from "./use-scroll-reset";
import {
initialCanvasColumnState,
openChannelInCanvas,
resolveCanvasVisibility,
toggleCanvasColumn,
} from "./canvas-column-state";
import { CanvasAvailabilityProvider } from "./canvas-availability";
import { CanvasColumn } from "./canvas-column";
import { ContextualPanel } from "./contextual-panel";
import { Rail } from "./rail";
Expand Down Expand Up @@ -43,27 +47,52 @@ export function AppShell({
// Route changes must not inherit the previous page's scroll position.
useScrollReset(mainRef, path);

// A deep link or in-app channel navigation feeds the canvas the same
// channel id the URL carries. Closing the canvas does not clear the URL
// here — the toggle only flips open/closed so reopening lands on the
// same conversation.
useEffect(() => {
const channelId = channelIdFromPath(path);
if (channelId === null) return;
setCanvasState((state) => openChannelInCanvas(state, channelId));
}, [path]);

const handleChannelChange = (channelId: string) => {
setCanvasState((state) => openChannelInCanvas(state, channelId));
if (!isChannelPath(path) || channelIdFromPath(path) !== channelId) {
navigate(channelPath(channelId));
}
};

return (
<div className="shell-frame" ref={frameRef}>
<Rail
path={path}
onNavigate={navigate}
user={user}
onSignOut={onSignOut}
/>
{contextualPanelVisible(layoutMode) && (
<ContextualPanel
<CanvasAvailabilityProvider allowed={canvasAllowed}>
<div className="shell-frame" ref={frameRef}>
<Rail
path={path}
onNavigate={navigate}
canvasOpen={canvasState.open}
onToggleCanvas={() => setCanvasState(toggleCanvasColumn)}
canvasAllowed={canvasAllowed}
user={user}
onSignOut={onSignOut}
/>
)}
<div className="shell-main" ref={mainRef}>
<div className="shell-main-content">{children}</div>
{contextualPanelVisible(layoutMode) && (
<ContextualPanel
path={path}
onNavigate={navigate}
canvasOpen={canvasState.open}
onToggleCanvas={() => setCanvasState(toggleCanvasColumn)}
canvasAllowed={canvasAllowed}
/>
)}
<div className="shell-main" ref={mainRef}>
<div className="shell-main-content">{children}</div>
</div>
{canvasAllowed && (
<CanvasColumn
open={canvasOpen}
channelId={canvasState.channelId}
onChannelChange={handleChannelChange}
/>
)}
</div>
{canvasAllowed && <CanvasColumn open={canvasOpen} />}
</div>
</CanvasAvailabilityProvider>
);
}
Loading
Loading