diff --git a/apps/web/README.md b/apps/web/README.md index d775570af..6023f76a4 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -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 diff --git a/apps/web/src/channel-path.ts b/apps/web/src/channel-path.ts new file mode 100644 index 000000000..d20efe5f7 --- /dev/null +++ b/apps/web/src/channel-path.ts @@ -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)}`; +} diff --git a/apps/web/src/command-palette-provider.tsx b/apps/web/src/command-palette-provider.tsx index 0a5941572..7d77bf84d 100644 --- a/apps/web/src/command-palette-provider.tsx +++ b/apps/web/src/command-palette-provider.tsx @@ -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)}`); diff --git a/apps/web/src/pages/agents-page.tsx b/apps/web/src/pages/agents-page.tsx index 03946dda6..6f0a83419 100644 --- a/apps/web/src/pages/agents-page.tsx +++ b/apps/web/src/pages/agents-page.tsx @@ -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"; @@ -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) { @@ -271,7 +272,7 @@ function AgentDetailPanel({ } function handleOpenInChannel() { - navigate?.("/chat"); + navigate?.(channelPath(null)); } return ( @@ -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(""); diff --git a/apps/web/src/pages/chat-page.tsx b/apps/web/src/pages/chat-page.tsx index 7f6d50244..0670cb9f7 100644 --- a/apps/web/src/pages/chat-page.tsx +++ b/apps/web/src/pages/chat-page.tsx @@ -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, @@ -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 ( + } + 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") { @@ -41,10 +55,8 @@ export function ChatPage({ - navigate(`${CHAT_PATH_PREFIX}/${encodeURIComponent(channelId)}`) - } + channelId={channelId} + onChannelChange={(nextChannelId) => navigate(channelPath(nextChannelId))} /> ); } diff --git a/apps/web/src/pages/home-page.tsx b/apps/web/src/pages/home-page.tsx index 290ed29dc..4844f73bc 100644 --- a/apps/web/src/pages/home-page.tsx +++ b/apps/web/src/pages/home-page.tsx @@ -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.", }, { diff --git a/apps/web/src/pages/onboarding-page.tsx b/apps/web/src/pages/onboarding-page.tsx index 685d271be..c96877ad9 100644 --- a/apps/web/src/pages/onboarding-page.tsx +++ b/apps/web/src/pages/onboarding-page.tsx @@ -310,7 +310,7 @@ export function OnboardingPage() { > @@ -332,7 +332,7 @@ export function OnboardingPage() { > - diff --git a/apps/web/src/routes.tsx b/apps/web/src/routes.tsx index 89f25fb0c..abc018ee0 100644 --- a/apps/web/src/routes.tsx +++ b/apps/web/src/routes.tsx @@ -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, @@ -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"; @@ -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", @@ -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/"); @@ -75,8 +76,8 @@ export function matchesRoute(routePath: string, path: string): boolean { export const APP_ROUTES: readonly AppRoute[] = [ { path: "/", label: "Home", icon: , render: () => }, { - path: "/chat", - label: "Chat", + path: CHANNEL_PATH_PREFIX, + label: "Channels", icon: , render: (path: string, navigate: (to: string) => void) => ( @@ -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), ); diff --git a/apps/web/src/shell/app-shell.tsx b/apps/web/src/shell/app-shell.tsx index 3748cae02..edcf6c54d 100644 --- a/apps/web/src/shell/app-shell.tsx +++ b/apps/web/src/shell/app-shell.tsx @@ -1,11 +1,13 @@ // 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"; @@ -13,9 +15,11 @@ 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"; @@ -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 ( -
- - {contextualPanelVisible(layoutMode) && ( - +
+ setCanvasState(toggleCanvasColumn)} - canvasAllowed={canvasAllowed} + user={user} + onSignOut={onSignOut} /> - )} -
-
{children}
+ {contextualPanelVisible(layoutMode) && ( + setCanvasState(toggleCanvasColumn)} + canvasAllowed={canvasAllowed} + /> + )} +
+
{children}
+
+ {canvasAllowed && ( + + )}
- {canvasAllowed && } -
+ ); } diff --git a/apps/web/src/shell/canvas-availability.tsx b/apps/web/src/shell/canvas-availability.tsx new file mode 100644 index 000000000..65588fd00 --- /dev/null +++ b/apps/web/src/shell/canvas-availability.tsx @@ -0,0 +1,25 @@ +// Whether the shell has room for the canvas column. Channel routes use this +// to decide between rendering chat in the main pane (compact/narrow) and +// leaving the conversation to the canvas (expanded). + +import { createContext, useContext, type ReactNode } from "react"; + +const CanvasAvailabilityContext = createContext(false); + +export function CanvasAvailabilityProvider({ + allowed, + children, +}: { + readonly allowed: boolean; + readonly children: ReactNode; +}) { + return ( + + {children} + + ); +} + +export function useCanvasColumnAvailable(): boolean { + return useContext(CanvasAvailabilityContext); +} diff --git a/apps/web/src/shell/canvas-column-state.ts b/apps/web/src/shell/canvas-column-state.ts index 673ffceeb..e0162d6bc 100644 --- a/apps/web/src/shell/canvas-column-state.ts +++ b/apps/web/src/shell/canvas-column-state.ts @@ -1,22 +1,49 @@ -// The canvas column's open/closed state as a pure reducer, separate from -// `breakpoints.ts`'s allow/disallow rule — a user's toggle and the -// viewport's veto are two independent inputs, and `resolveCanvasVisibility` -// is the one place they combine. +// The canvas column's state as a pure reducer, separate from `breakpoints.ts`'s +// allow/disallow rule — a user's toggle, a channel the user opened into the +// canvas, and the viewport's veto are three independent inputs, and +// `resolveCanvasVisibility` is the one place they combine. +// +// The canvas hosts the channel chat surface (the retired `/chat` page's +// `ChatWorkspace`), so its state carries the active channel alongside +// open/closed. A deep link (`/c/:channelId`) feeds the same `channelId` from +// the URL in `app-shell.tsx`; this reducer only owns the toggle-and-channel +// shape, never the URL. -export type CanvasColumnState = { readonly open: boolean }; +export type CanvasColumnState = { + readonly open: boolean; + /** The channel rendered in the canvas, or null when no channel is loaded. */ + readonly channelId: string | null; +}; export function initialCanvasColumnState(): CanvasColumnState { - return { open: false }; + return { open: false, channelId: null }; } +/** Flip the canvas open/closed without touching the loaded channel — closing + * and reopening lands on the same conversation. */ export function toggleCanvasColumn( state: CanvasColumnState, ): CanvasColumnState { - return { open: !state.open }; + return { ...state, open: !state.open }; } -/** What actually renders: the user's toggle, gated by whether the current - * viewport has room for a fourth column at all. */ +/** Open the canvas onto a specific channel (a channel-row click). */ +export function openChannelInCanvas( + _state: CanvasColumnState, + channelId: string, +): CanvasColumnState { + return { open: true, channelId }; +} + +/** Close the canvas and drop the loaded channel. */ +export function closeCanvasColumn( + _state: CanvasColumnState, +): CanvasColumnState { + return { open: false, channelId: null }; +} + +/** What actually renders: the user's toggle (or a deep-link channel), gated by + * whether the current viewport has room for a fourth column at all. */ export function resolveCanvasVisibility( state: CanvasColumnState, allowed: boolean, diff --git a/apps/web/src/shell/canvas-column.tsx b/apps/web/src/shell/canvas-column.tsx index 63cf8186a..2bffeb071 100644 --- a/apps/web/src/shell/canvas-column.tsx +++ b/apps/web/src/shell/canvas-column.tsx @@ -1,8 +1,8 @@ // Column 4: the optional canvas. Collapsed, it takes no space at all — the -// main pane gets the width back — and open, it hosts whatever a running -// agent, a live workflow walkthrough, or an analytics view will render -// later. Today nothing runs, so it says exactly that: no fabricated -// activity, no id standing in for content that doesn't exist yet. +// main pane gets the width back — and open, it hosts the channel chat +// surface (the retired `/chat` page's `ChatWorkspace`). Agent runs and +// live workflow walkthroughs will share this column later; today a channel +// is the only content it can load. // // The collapse/expand motion lives entirely in `shell.css` as a CSS // transition on `transform`/`opacity` (plus width, so the main pane @@ -13,7 +13,11 @@ // CSS, by shortening the transition to near-zero. import { Button, EmptyState } from "@corbits/react-ui"; -import { LayoutPanelLeft, PanelRightClose } from "lucide-react"; +import { ChatWorkspace } from "@corbits/chat-ui"; +import type { TenantResolution } from "@corbits/chat-ui"; +import { LayoutPanelLeft, MessageSquare, PanelRightClose } from "lucide-react"; + +import { useBench } from "../bench-context"; export function CanvasToggle({ open, @@ -36,21 +40,52 @@ export function CanvasToggle({ ); } -export function CanvasColumn({ open }: { readonly open: boolean }) { +export function CanvasColumn({ + open, + channelId, + onChannelChange, +}: { + readonly open: boolean; + readonly channelId: string | null; + readonly onChannelChange: (channelId: string) => void; +}) { + const { memberships, selectedTenantId, selectedPrincipalId } = useBench(); + + let tenant: TenantResolution; + if (memberships.kind !== "ready") { + tenant = memberships; + } else { + tenant = + selectedTenantId === null + ? { kind: "empty" } + : { kind: "ready", tenantId: selectedTenantId }; + } + const principalId = selectedPrincipalId ?? undefined; + // `inert` rather than `aria-hidden`: a collapsed column has to be out of // both the accessibility tree and the tab order, and `aria-hidden` alone // only does the first — a focusable descendant inside an `aria-hidden` // subtree is an ARIA violation, and the browser moves focus out of an - // `inert` subtree for us when it closes. The placeholder holds nothing - // focusable today; the running-agent content this column is being built - // for will. + // `inert` subtree for us when it closes. return (
- + {channelId === null ? ( + } + title="No channel open" + description="Pick a channel from the panel, or open one from Agents or the command palette." + /> + ) : ( + + )}
); diff --git a/apps/web/src/shell/panel-contributions.tsx b/apps/web/src/shell/panel-contributions.tsx index 0e27684c9..9a71f3ca2 100644 --- a/apps/web/src/shell/panel-contributions.tsx +++ b/apps/web/src/shell/panel-contributions.tsx @@ -5,6 +5,7 @@ import { EmptyState, SidebarItemRow, Skeleton } from "@corbits/react-ui"; import { Hash, MessageSquare, Workflow, Bell } from "lucide-react"; import { useBench } from "../bench-context"; +import { channelIdFromPath, channelPath, isChannelPath } from "../channel-path"; import { useBenchActivity } from "./bench-activity"; import { registerPanelContribution, @@ -12,18 +13,10 @@ import { } from "./panel-contribution"; import type { RoutineActivityItem } from "./routine-activity"; -const CHAT_PATH_PREFIX = "/chat"; - function pathMatches(prefix: string, path: string): boolean { return path === prefix || path.startsWith(`${prefix}/`); } -function activeChatChannelId(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); -} - function ChannelsBand({ path, onNavigate, @@ -33,7 +26,7 @@ function ChannelsBand({ }) { const { selectedTenantId } = useBench(); const activity = useBenchActivity(selectedTenantId); - const activeId = activeChatChannelId(path); + const activeId = channelIdFromPath(path); if (activity.kind === "loading") { return ; @@ -64,7 +57,7 @@ function ChannelsBand({ } title="No channels yet" - description="Create a channel from Chat to start a conversation." + description="Create a channel to start a conversation." /> ); } @@ -79,11 +72,7 @@ function ChannelsBand({ key={channel.id} name={channel.title || "Untitled channel"} selected={channel.id === activeId} - onSelect={() => - onNavigate( - `${CHAT_PATH_PREFIX}/${encodeURIComponent(channel.id)}`, - ) - } + onSelect={() => onNavigate(`${channelPath(channel.id)}`)} /> ))}
@@ -96,11 +85,7 @@ function ChannelsBand({ key={channel.id} name={channel.title || "Untitled chat"} selected={channel.id === activeId} - onSelect={() => - onNavigate( - `${CHAT_PATH_PREFIX}/${encodeURIComponent(channel.id)}`, - ) - } + onSelect={() => onNavigate(`${channelPath(channel.id)}`)} /> ))} @@ -171,7 +156,7 @@ function LiveActivityBand({ // Home and other surfaces share the live pulse: channels + running routines. const { selectedTenantId } = useBench(); const activity = useBenchActivity(selectedTenantId); - const activeId = activeChatChannelId(path); + const activeId = channelIdFromPath(path); if (activity.kind === "loading") { return ; @@ -233,11 +218,7 @@ function LiveActivityBand({ key={channel.id} name={channel.title || "Untitled channel"} selected={channel.id === activeId} - onSelect={() => - onNavigate( - `${CHAT_PATH_PREFIX}/${encodeURIComponent(channel.id)}`, - ) - } + onSelect={() => onNavigate(`${channelPath(channel.id)}`)} /> ))} @@ -250,11 +231,7 @@ function LiveActivityBand({ key={channel.id} name={channel.title || "Untitled chat"} selected={channel.id === activeId} - onSelect={() => - onNavigate( - `${CHAT_PATH_PREFIX}/${encodeURIComponent(channel.id)}`, - ) - } + onSelect={() => onNavigate(`${channelPath(channel.id)}`)} /> ))} @@ -288,18 +265,18 @@ export function ensurePanelContributions(): void { }); registerPanelContribution({ - id: "chat", - match: (path) => pathMatches("/chat", path), + id: "channels", + match: (path) => isChannelPath(path), pageBand: (ctx) => ({ - title: "Chat", - subtitle: "Channels and conversations", + title: "Channels", + subtitle: "Open a conversation in the canvas", actions: [ { id: "new-channel", label: "New channel", onSelect: () => { window.dispatchEvent(new CustomEvent("workbench:chat:new-channel")); - if (!pathMatches("/chat", ctx.path)) ctx.onNavigate("/chat"); + if (!isChannelPath(ctx.path)) ctx.onNavigate(channelPath(null)); }, }, ], diff --git a/apps/web/test/canvas-column-state.test.ts b/apps/web/test/canvas-column-state.test.ts new file mode 100644 index 000000000..41b2b49a2 --- /dev/null +++ b/apps/web/test/canvas-column-state.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, test } from "bun:test"; + +import { + closeCanvasColumn, + initialCanvasColumnState, + openChannelInCanvas, + resolveCanvasVisibility, + toggleCanvasColumn, +} from "../src/shell/canvas-column-state"; + +describe("canvas column state", () => { + test("starts closed with no channel", () => { + expect(initialCanvasColumnState()).toEqual({ + open: false, + channelId: null, + }); + }); + + test("opening a channel loads it and opens the canvas", () => { + const next = openChannelInCanvas(initialCanvasColumnState(), "ch_1"); + expect(next).toEqual({ open: true, channelId: "ch_1" }); + }); + + test("toggle preserves the loaded channel", () => { + const open = openChannelInCanvas(initialCanvasColumnState(), "ch_1"); + const closed = toggleCanvasColumn(open); + expect(closed).toEqual({ open: false, channelId: "ch_1" }); + expect(toggleCanvasColumn(closed)).toEqual(open); + }); + + test("close drops the channel", () => { + const open = openChannelInCanvas(initialCanvasColumnState(), "ch_1"); + expect(closeCanvasColumn(open)).toEqual({ open: false, channelId: null }); + }); + + test("visibility is gated by the viewport allow flag", () => { + const open = openChannelInCanvas(initialCanvasColumnState(), "ch_1"); + expect(resolveCanvasVisibility(open, true)).toBe(true); + expect(resolveCanvasVisibility(open, false)).toBe(false); + expect(resolveCanvasVisibility(initialCanvasColumnState(), true)).toBe( + false, + ); + }); +}); diff --git a/apps/web/test/channel-path.test.ts b/apps/web/test/channel-path.test.ts new file mode 100644 index 000000000..cc90b366e --- /dev/null +++ b/apps/web/test/channel-path.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; + +import { + channelIdFromPath, + channelPath, + isChannelPath, +} from "../src/channel-path"; + +describe("channelPath helpers", () => { + test("builds canonical /c paths", () => { + expect(channelPath(null)).toBe("/c"); + expect(channelPath("ch_1")).toBe("/c/ch_1"); + expect(channelPath("ch/with/slash")).toBe("/c/ch%2Fwith%2Fslash"); + }); + + test("parses /c and legacy /chat deep links", () => { + expect(channelIdFromPath("/c")).toBeNull(); + expect(channelIdFromPath("/c/ch_1")).toBe("ch_1"); + expect(channelIdFromPath("/chat/ch_1")).toBe("ch_1"); + expect(channelIdFromPath("/routines")).toBeNull(); + }); + + test("isChannelPath covers both prefixes", () => { + expect(isChannelPath("/c")).toBe(true); + expect(isChannelPath("/c/ch_1")).toBe(true); + expect(isChannelPath("/chat")).toBe(true); + expect(isChannelPath("/chat/ch_1")).toBe(true); + expect(isChannelPath("/")).toBe(false); + }); +}); diff --git a/apps/web/test/panel-contribution.test.ts b/apps/web/test/panel-contribution.test.ts index 700352e9a..601d67bbc 100644 --- a/apps/web/test/panel-contribution.test.ts +++ b/apps/web/test/panel-contribution.test.ts @@ -7,7 +7,7 @@ describe("createPanelRegistry", () => { const registry = createPanelRegistry([ { id: "chat", - match: (path) => path === "/chat" || path.startsWith("/chat/"), + match: (path) => path === "/c" || path.startsWith("/c/"), pageBand: () => ({ title: "Chat" }), }, { @@ -17,7 +17,7 @@ describe("createPanelRegistry", () => { }, ]); - expect(registry.resolve("/chat/abc")?.id).toBe("chat"); + expect(registry.resolve("/c/abc")?.id).toBe("chat"); expect(registry.resolve("/")?.id).toBe("home"); expect(registry.resolve("/unknown")).toBeNull(); }); @@ -56,7 +56,7 @@ describe("createPanelRegistry", () => { id: "c1", kind: "channel" as const, label: "ops", - href: "/chat/c1", + href: "/c/c1", }, ]; expect(registry.resolve("/routines")?.id).toBe("routines"); diff --git a/apps/web/test/pins.test.ts b/apps/web/test/pins.test.ts index ffe09d38b..ed1a5ce69 100644 --- a/apps/web/test/pins.test.ts +++ b/apps/web/test/pins.test.ts @@ -29,7 +29,7 @@ describe("pins", () => { id: "ch_1", kind: "channel" as const, label: "general", - href: "/chat/ch_1", + href: "/c/ch_1", }, ]; savePins(pins, storage); diff --git a/apps/web/test/routes.test.tsx b/apps/web/test/routes.test.tsx index 21207e041..d5e0fc0ee 100644 --- a/apps/web/test/routes.test.tsx +++ b/apps/web/test/routes.test.tsx @@ -9,7 +9,12 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; import { App } from "../src/app"; -import { APP_ROUTES, NAV_ROUTES, SETTINGS_PATH } from "../src/routes"; +import { + APP_ROUTES, + matchesRoute, + NAV_ROUTES, + SETTINGS_PATH, +} from "../src/routes"; import type { SessionState } from "../src/session"; const noNavigate = () => undefined; @@ -63,7 +68,7 @@ describe("route table", () => { test("covers every screen the app can route to", () => { expect(APP_ROUTES.map((route) => route.path)).toEqual([ "/", - "/chat", + "/c", "/routines", "/library", "/agents", @@ -83,6 +88,12 @@ describe("route table", () => { "Insights", ]); }); + + test("legacy /chat paths still match the channels route", () => { + expect(matchesRoute("/c", "/chat")).toBe(true); + expect(matchesRoute("/c", "/chat/ch_1")).toBe(true); + expect(matchesRoute("/c", "/c/ch_1")).toBe(true); + }); }); describe("routes render", () => { diff --git a/apps/web/test/shell.test.ts b/apps/web/test/shell.test.ts index 217cf8991..883d8c765 100644 --- a/apps/web/test/shell.test.ts +++ b/apps/web/test/shell.test.ts @@ -77,8 +77,8 @@ describe("canvas column state", () => { }); test("visibility requires both the toggle and the viewport to agree", () => { - const open = { open: true }; - const closed = { open: false }; + const open = { open: true, channelId: "ch_1" as string | null }; + const closed = { open: false, channelId: null as string | null }; expect(resolveCanvasVisibility(open, true)).toBe(true); expect(resolveCanvasVisibility(open, false)).toBe(false); expect(resolveCanvasVisibility(closed, true)).toBe(false);