From 2283637eab477a0d950dca40f8707d53c1be09c7 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 23:05:19 +0200 Subject: [PATCH 1/7] Improve workspace workflows and UI --- client/app/AppRouter.tsx | 2 +- .../features/applets/applet-runtime.test.ts | 114 ++++---- client/features/applets/applet-runtime.ts | 38 ++- client/features/applets/useApplet.ts | 19 +- .../chat/messages/MarkdownContent.test.tsx | 28 ++ .../chat/messages/MarkdownContent.tsx | 17 +- client/features/views/ViewManager.tsx | 14 +- client/features/workspace/UnavailablePage.tsx | 21 ++ client/features/workspace/WorkspaceScreen.tsx | 47 ++-- client/features/workspace/moi-context.ts | 2 +- .../features/workspace/tab-resolution.test.ts | 36 +-- client/features/workspace/tab-resolution.ts | 32 +-- .../workspace/useWorkspaceNavigation.ts | 255 +++++++++++------- client/runtime/useWorkspaceEvents.ts | 64 ++++- docs/applet-assets.md | 3 +- docs/navigation.md | 52 ++++ docs/rfc-intents-v2.md | 2 + lib/moi-context.ts | 2 +- lib/navigation.test.ts | 81 ++++++ lib/navigation.ts | 84 ++++++ lib/types.ts | 2 +- lib/workspace-tabs.test.ts | 18 +- lib/workspace-tabs.ts | 20 +- server/applets/build-applet.ts | 19 +- server/cli.ts | 95 ++++--- server/control.ts | 36 ++- server/moi-scaffold.ts | 11 +- server/navigation-relay.test.ts | 91 +++++++ server/navigation-relay.ts | 110 ++++++++ server/tabs.test.ts | 64 ++--- server/tabs.ts | 52 ++-- server/test/__fixtures__/with-focustab.tsx | 5 - server/test/__fixtures__/with-navigation.tsx | 5 + server/test/build-applet.test.ts | 8 +- server/test/cli-help.test.ts | 21 +- server/test/navigation-cli.test.ts | 64 +++++ server/test/skills-template.test.ts | 7 +- server/web.ts | 13 +- .../.claude/skills/moi-workspace/SKILL.md | 49 +++- .../moi-workspace/references/INTENTS.md | 62 ++--- 40 files changed, 1155 insertions(+), 510 deletions(-) create mode 100644 client/features/chat/messages/MarkdownContent.test.tsx create mode 100644 client/features/workspace/UnavailablePage.tsx create mode 100644 docs/navigation.md create mode 100644 lib/navigation.test.ts create mode 100644 lib/navigation.ts create mode 100644 server/navigation-relay.test.ts create mode 100644 server/navigation-relay.ts delete mode 100644 server/test/__fixtures__/with-focustab.tsx create mode 100644 server/test/__fixtures__/with-navigation.tsx create mode 100644 server/test/navigation-cli.test.ts diff --git a/client/app/AppRouter.tsx b/client/app/AppRouter.tsx index 7f31bb6e..656cb723 100644 --- a/client/app/AppRouter.tsx +++ b/client/app/AppRouter.tsx @@ -41,7 +41,7 @@ export function AppRouter() { - {/* The wildcard is the workspace tab id (`view:orders`, `overview`, …) — + {/* The wildcard is the destination path (`views/orders`, `overview`, …) — the URL is the tab address, read downstream with `useParams`. This pattern is its only definition. Keyed by workspace id only, so tab switches never remount the workspace tree. */} diff --git a/client/features/applets/applet-runtime.test.ts b/client/features/applets/applet-runtime.test.ts index e10c09ef..476331af 100644 --- a/client/features/applets/applet-runtime.test.ts +++ b/client/features/applets/applet-runtime.test.ts @@ -25,11 +25,9 @@ import { const VIEW: AppletIdentity = { kind: 'view', name: 'board' } const WIDGET: AppletIdentity = { kind: 'widget', name: 'clock' } -function subscribeFocus(workspaceId: string) { - const calls: [string, Record | undefined][] = [] - const unbind = appletRuntime(workspaceId).on('focusTab', (tab, params) => - calls.push([tab, params]) - ) +function subscribeNavigation(workspaceId: string) { + const calls: string[] = [] + const unbind = appletRuntime(workspaceId).on('navigate', href => calls.push(href)) return { calls, unbind } } @@ -40,34 +38,45 @@ function subscribeChat(workspaceId: string) { } describe('bridge validation', () => { - test('emits a well-formed call and narrows malformed params to undefined', () => { + test('emits URL navigation and rejects malformed addresses', () => { const ws = `ws-${crypto.randomUUID()}` - const { calls } = subscribeFocus(ws) + const { calls } = subscribeNavigation(ws) const { bridge } = appletRuntime(ws).connect(VIEW) + const log = spyOn(appletLog, 'reportAppletError').mockImplementation(() => {}) + bridge.navigate('moi:/views/orders?order=o-1') + bridge.navigate('moi:/overview') + bridge.navigate(['invalid']) + bridge.navigate('javascript:alert(1)') + expect(calls).toEqual(['moi:/views/orders?order=o-1', 'moi:/overview']) + expect(log).toHaveBeenCalledTimes(2) + log.mockRestore() + }) - bridge.focusTab('view:orders', { order: 'o-1' }) - bridge.focusTab('overview') - // Valid JSON, wrong shape — params must degrade, not leak through. - bridge.focusTab('view:orders', ['not', 'a', 'record']) - bridge.focusTab('view:orders', null) + test('resolves native anchor hrefs in the source workspace and disposes safely', () => { + const { bridge, dispose } = appletRuntime('ws-1').connect(VIEW) + expect(bridge.resolveHref('moi:/views/orders?order=o-1')).toBe( + '/workspace/ws-1/views/orders?order=o-1' + ) + expect(bridge.resolveHref('https://example.com/')).toBe('https://example.com/') + expect(() => bridge.resolveHref('javascript:alert(1)')).toThrow() + dispose() + expect(bridge.resolveHref('moi:/overview')).toBe('') + }) - expect(calls).toEqual([ - ['view:orders', { order: 'o-1' }], - ['overview', undefined], - ['view:orders', undefined], - ['view:orders', undefined] - ]) + test('resolves applet links with the host router base', () => { + const { bridge } = appletRuntime('prefixed').connect(VIEW, '/prefix') + expect(bridge.resolveHref('moi:/views/orders')).toBe('/prefix/workspace/prefixed/views/orders') }) - test('drops calls with a malformed tab id instead of emitting', () => { + test('drops calls with malformed addresses instead of emitting', () => { const ws = `ws-${crypto.randomUUID()}` - const { calls } = subscribeFocus(ws) + const { calls } = subscribeNavigation(ws) const { bridge } = appletRuntime(ws).connect(VIEW) - bridge.focusTab('not-a-tab') - bridge.focusTab('view:multi/segment') - bridge.focusTab(42) - bridge.focusTab({ toString: () => 'agent' }) + bridge.navigate('not-a-tab') + bridge.navigate('view:multi/segment') + bridge.navigate(42) + bridge.navigate({ toString: () => 'moi:/scratchpad' }) expect(calls).toEqual([]) }) @@ -75,24 +84,21 @@ describe('bridge validation', () => { test('emitting with no subscribers (screen unmounted) is a no-op', () => { const ws = `ws-${crypto.randomUUID()}` const { bridge } = appletRuntime(ws).connect(VIEW) - expect(() => bridge.focusTab('agent')).not.toThrow() + expect(() => bridge.navigate('moi:/scratchpad')).not.toThrow() }) test('an unbound subscriber stops receiving; others keep receiving', () => { const ws = `ws-${crypto.randomUUID()}` - const first = subscribeFocus(ws) - const second = subscribeFocus(ws) + const first = subscribeNavigation(ws) + const second = subscribeNavigation(ws) const { bridge } = appletRuntime(ws).connect(VIEW) - bridge.focusTab('agent') + bridge.navigate('moi:/scratchpad') first.unbind() - bridge.focusTab('overview') + bridge.navigate('moi:/overview') - expect(first.calls).toEqual([['agent', undefined]]) - expect(second.calls).toEqual([ - ['agent', undefined], - ['overview', undefined] - ]) + expect(first.calls).toEqual(['moi:/scratchpad']) + expect(second.calls).toEqual(['moi:/scratchpad', 'moi:/overview']) }) }) @@ -330,13 +336,13 @@ describe('sendChatMessage rate limiting', () => { describe('disposal', () => { test('a disposed connection is inert even while subscribers are live', () => { const ws = `ws-${crypto.randomUUID()}` - const { calls } = subscribeFocus(ws) + const { calls } = subscribeNavigation(ws) const { bridge, dispose } = appletRuntime(ws).connect(VIEW) - bridge.focusTab('agent') + bridge.navigate('moi:/scratchpad') dispose() - bridge.focusTab('agent') - expect(calls).toEqual([['agent', undefined]]) + bridge.navigate('moi:/scratchpad') + expect(calls).toEqual(['moi:/scratchpad']) }) }) @@ -348,29 +354,29 @@ function fakeModule() { __attachBridge: (next: AppletBridge) => { bridge = next }, - focusTab: (tab: unknown, params?: unknown) => bridge?.focusTab(tab, params) + navigate: (href: unknown) => bridge?.navigate(href) } } describe('attachAppletBridge', () => { test('wires a module to its workspace runtime; invalidateApplet neuters it', () => { const ws = `ws-${crypto.randomUUID()}` - const { calls } = subscribeFocus(ws) + const { calls } = subscribeNavigation(ws) const mod = fakeModule() attachAppletBridge(mod, ws, appletKey('views', ws, 'board'), VIEW) - mod.focusTab('view:board') - expect(calls).toEqual([['view:board', undefined]]) + mod.navigate('moi:/views/board') + expect(calls).toEqual(['moi:/views/board']) // The rebuild path: invalidation must leave the OLD module instance inert. invalidateApplet('views', ws, 'board') - mod.focusTab('view:board') - expect(calls).toEqual([['view:board', undefined]]) + mod.navigate('moi:/views/board') + expect(calls).toEqual(['moi:/views/board']) }) test('invalidateAppletSegment disposes bridges kind-wide', () => { const ws = `ws-${crypto.randomUUID()}` - const { calls } = subscribeFocus(ws) + const { calls } = subscribeNavigation(ws) const mod = fakeModule() const key = appletKey('widgets', ws, 'clock') @@ -379,7 +385,7 @@ describe('attachAppletBridge', () => { setCachedApplet(key, Promise.resolve(mod)) attachAppletBridge(mod, ws, key, WIDGET) invalidateAppletSegment('widgets') - mod.focusTab('overview') + mod.navigate('moi:/overview') expect(calls).toEqual([]) }) @@ -391,7 +397,7 @@ describe('attachAppletBridge', () => { test('re-attaching under a key disposes the previous connection', () => { const ws = `ws-${crypto.randomUUID()}` - const { calls } = subscribeFocus(ws) + const { calls } = subscribeNavigation(ws) const key = appletKey('views', ws, 'board') const oldMod = fakeModule() @@ -399,9 +405,9 @@ describe('attachAppletBridge', () => { const newMod = fakeModule() attachAppletBridge(newMod, ws, key, VIEW) - oldMod.focusTab('agent') - newMod.focusTab('overview') - expect(calls).toEqual([['overview', undefined]]) + oldMod.navigate('moi:/scratchpad') + newMod.navigate('moi:/overview') + expect(calls).toEqual(['moi:/overview']) }) }) @@ -409,11 +415,11 @@ describe('workspace isolation', () => { test('bridges reach only their own workspace runtime', () => { const wsA = `ws-${crypto.randomUUID()}` const wsB = `ws-${crypto.randomUUID()}` - const a = subscribeFocus(wsA) - const b = subscribeFocus(wsB) + const a = subscribeNavigation(wsA) + const b = subscribeNavigation(wsB) - appletRuntime(wsA).connect(VIEW).bridge.focusTab('agent') - expect(a.calls).toEqual([['agent', undefined]]) + appletRuntime(wsA).connect(VIEW).bridge.navigate('moi:/scratchpad') + expect(a.calls).toEqual(['moi:/scratchpad']) expect(b.calls).toEqual([]) }) }) diff --git a/client/features/applets/applet-runtime.ts b/client/features/applets/applet-runtime.ts index a8c9a136..963bbc10 100644 --- a/client/features/applets/applet-runtime.ts +++ b/client/features/applets/applet-runtime.ts @@ -10,7 +10,7 @@ // // Applet calls surface as runtime EVENTS: the bridge validates the untrusted // args, then emits, and each host feature subscribes to its own concern with -// `useAppletEvent` (navigation owns `focusTab`; chat will own `sendChatMessage`) +// `useAppletEvent` (navigation owns `navigate`; chat will own `sendChatMessage`) // — no central handlers object assembled by the screen. Applet → host only; // if a host → applet direction is ever added (`moi.on(...)`), `dispose` must // also unbind those listeners or a disposed module leaks. @@ -29,8 +29,9 @@ import { reportAppletError } from '@/client/features/applets/applet-log' import { toast } from '@/client/components/ui/toast' import { createRateLimiter, type RateLimiter } from '@/client/lib/rate-limit' import { useLatestRef } from '@/client/lib/use-latest-ref' -import type { AppletKind, WorkspaceTabId } from '@/lib/types' -import { isParamsRecord, isWorkspaceTabId } from '@/lib/workspace-tabs' +import type { AppletKind } from '@/lib/types' +import { isParamsRecord } from '@/lib/workspace-tabs' +import { resolveWorkspaceHref } from '@/lib/navigation' // Which applet a bridge belongs to, supplied by the host at attach time. export type AppletIdentity = { kind: AppletKind; name: string } @@ -49,10 +50,7 @@ export type AppletChatMessage = { // Events a workspace runtime emits — already validated, typed for host code. export type AppletEvents = { addChatAttachment: (attachment: AttachmentInput & AttachmentOrigin) => void - // Client-local replace-navigation to a workspace tab. `params` reach the - // target view as its `params` prop via navigation state — JSON-plain only - // (history state is structured-cloned). - focusTab: (tab: WorkspaceTabId, params?: Record) => void + navigate: (href: string) => void // A message for the workspace's active chat, sent as if the user typed // `message`, with attachments prepared before the send. sendChatMessage: (message: AppletChatMessage) => void @@ -63,7 +61,8 @@ export type AppletEvents = { // them before emitting. export type AppletBridge = { addChatAttachment: (input: unknown) => void - focusTab: (tab: unknown, params?: unknown) => void + navigate: (href: unknown) => void + resolveHref: (href: unknown) => string sendChatMessage: (input: unknown, context?: unknown) => void } @@ -125,7 +124,7 @@ function createRuntime(workspaceId: string) { // call instead of being emitted — and `dispose` flips the connection dead // so a disposed module can never act again. Emitting with no subscribers // (workspace screen unmounted) is a no-op by nanoevents semantics. - connect(identity: AppletIdentity) { + connect(identity: AppletIdentity, base = '') { let alive = true const source = appletSource(identity) const bridge: AppletBridge = { @@ -140,10 +139,20 @@ function createRuntime(workspaceId: string) { drop(identity, `addChatAttachment() was dropped: ${message}`) } }, - focusTab(tab, params) { + navigate(href) { if (!alive) return - if (!isWorkspaceTabId(tab)) return - emitter.emit('focusTab', tab, isParamsRecord(params) ? params : undefined) + try { + if (typeof href !== 'string') throw new Error('Navigation requires a URL string') + resolveWorkspaceHref(workspaceId, href, base) + emitter.emit('navigate', href) + } catch (error) { + drop(identity, `navigate() was dropped: ${errorMessage(error)}`) + } + }, + resolveHref(href) { + if (!alive) return '' + if (typeof href !== 'string') throw new Error('resolveHref requires a URL string') + return resolveWorkspaceHref(workspaceId, href, base) }, sendChatMessage(input, legacyContext) { if (!alive) return @@ -287,14 +296,15 @@ export function attachAppletBridge( mod: unknown, workspaceId: string, key: string, - identity: AppletIdentity + identity: AppletIdentity, + base = '' ): void { const attach = (mod as BridgeModule).__attachBridge if (typeof attach !== 'function') return // A key is re-attached only after invalidation disposed it, but never leave // a live orphan connection behind if that ordering ever changes. connections.get(key)?.() - const { bridge, dispose } = appletRuntime(workspaceId).connect(identity) + const { bridge, dispose } = appletRuntime(workspaceId).connect(identity, base) connections.set(key, dispose) attach(bridge) } diff --git a/client/features/applets/useApplet.ts b/client/features/applets/useApplet.ts index d11f3b99..ad65dd7a 100644 --- a/client/features/applets/useApplet.ts +++ b/client/features/applets/useApplet.ts @@ -1,4 +1,5 @@ import type { ComponentType } from 'react' +import { useRouter } from 'wouter' import { useCallback, useEffect, useState } from 'react' import { @@ -16,12 +17,10 @@ import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' import { type WorkspaceEvent, useWorkspaceEvent } from '@/client/runtime/useWorkspaceEvents' import type { AppletKind } from '@/lib/types' -// The props the host passes to a mounted applet component. Views receive -// `params` from navigation state (focusTab / `moi tabs focus` → the URL's -// history entry — see ViewApp in WorkspaceScreen.tsx); widgets are mounted -// bare, so the applet must render sensibly with `params` absent. +// Views receive URL query params. Widgets mount without params; applets must +// render sensibly with missing fields. export type AppletComponentProps = { - params?: Record + params?: Record } export type AppletState = @@ -59,7 +58,8 @@ const VIEW_KIND: AppletKindSpec = { function loadApplet( kind: AppletKindSpec, workspaceId: string, - name: string + name: string, + base: string ): Promise> { const { segment } = kind const key = appletKey(segment, workspaceId, name) @@ -75,7 +75,7 @@ function loadApplet( // runtime — the only moment the namespace is in hand, before React renders // the component. Disposal rides invalidateApplet (same key). The identity // passed here is what the applet's chat messages are attributed to. - attachAppletBridge(mod, workspaceId, key, { kind: kind.kind, name }) + attachAppletBridge(mod, workspaceId, key, { kind: kind.kind, name }, base) return mod.default as ComponentType }) @@ -85,11 +85,12 @@ function loadApplet( function useApplet(kind: AppletKindSpec, name: string): AppletState { const workspaceId = useWorkspaceId() + const { base } = useRouter() const [state, setState] = useState({ status: 'loading', version: 0 }) const load = useCallback(() => { setState(prev => ({ status: 'loading', version: prev.version })) - loadApplet(kind, workspaceId, name) + loadApplet(kind, workspaceId, name, base) .then(Component => setState(prev => ({ status: 'ready', Component, version: prev.version + 1 })) ) @@ -105,7 +106,7 @@ function useApplet(kind: AppletKindSpec, name: string): AppletState { }) setState(prev => ({ status: 'error', error: String(err), version: prev.version + 1 })) }) - }, [kind, workspaceId, name]) + }, [base, kind, workspaceId, name]) useEffect(() => { load() diff --git a/client/features/chat/messages/MarkdownContent.test.tsx b/client/features/chat/messages/MarkdownContent.test.tsx new file mode 100644 index 00000000..4dcea3e6 --- /dev/null +++ b/client/features/chat/messages/MarkdownContent.test.tsx @@ -0,0 +1,28 @@ +import { expect, test } from 'bun:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { Router } from 'wouter' +import { Workspace } from '@/client/features/workspace/WorkspaceContext' +import { MarkdownContent } from './MarkdownContent' + +test('chat renders portable links as native workspace hrefs with the deployment base', () => { + const html = renderToStaticMarkup( + + + + + + ) + expect(html).toContain('href="/prefix/workspace/abc/views/events?eventId=123"') + expect(html).toContain('href="https://example.com/"') +}) + +test('invalid moi links and executable URLs stay sanitized, including images', () => { + const html = renderToStaticMarkup( + + + + ) + expect(html).not.toContain('href="moi:') + expect(html).not.toContain('javascript:') + expect(html).not.toContain('src="moi:') +}) diff --git a/client/features/chat/messages/MarkdownContent.tsx b/client/features/chat/messages/MarkdownContent.tsx index 6757b876..1cd309c9 100644 --- a/client/features/chat/messages/MarkdownContent.tsx +++ b/client/features/chat/messages/MarkdownContent.tsx @@ -1,9 +1,12 @@ import type { ComponentProps } from 'react' +import { useRouter } from 'wouter' -import ReactMarkdown, { type ExtraProps } from 'react-markdown' +import ReactMarkdown, { defaultUrlTransform, type ExtraProps } from 'react-markdown' import rehypeHighlight from 'rehype-highlight' import remarkGfm from 'remark-gfm' +import { useWorkspaceId } from '@/client/features/workspace/WorkspaceContext' +import { resolveWorkspaceHref } from '@/lib/navigation' import { cn } from '@/client/lib/cn' const remarkPlugins = [remarkGfm] @@ -44,6 +47,8 @@ type PlainMarkdownTextProps = { } export function MarkdownContent({ size = 'sm', content }: MarkdownContentProps) { + const workspaceId = useWorkspaceId() + const { base } = useRouter() return (
{ + if (node.tagName === 'a' && key === 'href' && url.startsWith('moi:')) { + try { + return resolveWorkspaceHref(workspaceId, url, base) + } catch { + return '' + } + } + return defaultUrlTransform(url) + }} > {content} diff --git a/client/features/views/ViewManager.tsx b/client/features/views/ViewManager.tsx index cd56e81f..deef15ae 100644 --- a/client/features/views/ViewManager.tsx +++ b/client/features/views/ViewManager.tsx @@ -43,10 +43,8 @@ type ViewManagerProps = { // manager stays mounted either way — that is what makes coming back from the // agent or Overview tab instant too. activeViewId: string | null - // The active view's addressable state, read from navigation state (focusTab / - // `moi tabs focus`). `{}` on a fresh mount, a new browser tab, or a plain - // tab-bar click — a view must render sensibly with that. - params: Record + // The active view's URL query params. Parked views retain their last values. + params: Record } // Memoized against the workspace screen's own churn: the screen re-renders on @@ -55,7 +53,7 @@ type ViewManagerProps = { // resident view — typing next to a streaming agent then janks on a big view. // All three props are identity-stable outside real changes: `views` comes from // the workspace query cache, `activeViewId` is a string, and `params` is -// memoized by navigation state. +// memoized by the query string. export const ViewManager = memo(function ViewManager({ views, activeViewId, @@ -122,7 +120,7 @@ function useResidentViews(activeId: string | null, views: ViewInfo[]): ResidentV type ViewSlotProps = { view: ViewInfo active: boolean - params: Record + params: Record } // One resident view. The bundle it holds is loaded and kept fresh for as long @@ -133,7 +131,7 @@ function ViewSlot({ view, active, params }: ViewSlotProps) { const bundle = useView(view.id) const { current, outgoing } = useLoadedBundle(bundle, active) // A parked view keeps rendering with the params it was last shown with: the - // active view's `focusTab` state is not its to render. + // active view's URL state is not its to render. const [shownParams, setShownParams] = useState(params) if (active && shownParams !== params) setShownParams(params) @@ -194,7 +192,7 @@ function ViewSlot({ view, active, params }: ViewSlotProps) { type ViewFrameProps = { view: ViewInfo build: ViewBuild - params: Record + params: Record // Play the rebuild dissolve. Set on the incoming build only, and only while // the build it replaced is still rendered underneath it. entering?: boolean diff --git a/client/features/workspace/UnavailablePage.tsx b/client/features/workspace/UnavailablePage.tsx new file mode 100644 index 00000000..dddaefd7 --- /dev/null +++ b/client/features/workspace/UnavailablePage.tsx @@ -0,0 +1,21 @@ +import { IconFileSearch } from '@tabler/icons-react' + +import { Button } from '@/client/components/ui/button' + +type UnavailablePageProps = { + onOpenOverview: () => void +} + +export function UnavailablePage({ onOpenOverview }: UnavailablePageProps) { + return ( +
+
+ +

There's no such page in this workspace

+
+ +
+ ) +} diff --git a/client/features/workspace/WorkspaceScreen.tsx b/client/features/workspace/WorkspaceScreen.tsx index 80344044..a3906002 100644 --- a/client/features/workspace/WorkspaceScreen.tsx +++ b/client/features/workspace/WorkspaceScreen.tsx @@ -20,7 +20,6 @@ import { ChatPanel } from '@/client/features/chat/ChatPanel' import type { WelcomeDestination } from '@/client/features/chat/messages/ChatEmptyState' import { ChatPopup } from '@/client/features/chat/ChatPopup' import { ThemePanel } from '@/client/features/workspace/ThemePanel' -import { useAppletEvent } from '@/client/features/applets/applet-runtime' import { Overview } from '@/client/features/overview/Overview' import { PanelHeader } from '@/client/components/shared/PanelHeader' import { WorkspaceIcon } from '@/client/components/shared/WorkspaceIcon' @@ -88,6 +87,7 @@ import { } from '@/lib/workspace-tabs' import { WorkspaceSplitLayout } from './WorkspaceSplitLayout' +import { UnavailablePage } from './UnavailablePage' const Scratchpad = lazy(() => import('@/client/features/scratchpad/Scratchpad').then(module => ({ @@ -259,7 +259,15 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro // The tab address: URL in, active tab + applet params out, plus the persisted // tab state it keeps in sync. See useWorkspaceNavigation for the invariants. - const { tabsState, activeTab, appletParams, navigateToTab, setTabs } = useWorkspaceNavigation({ + const { + tabsState, + activeTab, + appletParams, + navigateToTab, + setTabs, + isUnavailable, + onNavigationClick + } = useWorkspaceNavigation({ views, builders, split: dockedSplit @@ -369,7 +377,7 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro // The URL follows a replaced builder tab to the view that took its place. const urlReplacement = replacements.get(activeTab) - if (urlReplacement) navigateToTab(urlReplacement) + if (urlReplacement) navigateToTab(urlReplacement, { replace: true }) const replacementViews = new Set(replacements.values()) const sourceForView = new Map( @@ -407,8 +415,8 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro // Tab switching is navigation; the saved default and the open set follow via // the navigation hook. Only the chat side effects belong to the screen. - const openTab = (tab: WorkspaceTabId, params?: Record) => { - navigateToTab(tab, params) + const openTab = (tab: WorkspaceTabId) => { + navigateToTab(tab) if (tab === 'agent') { setFloatingChatOpen(false) setChatFocusRequest(request => request + 1) @@ -432,18 +440,8 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro setFloatingChatOpen(false) } - // Focus requests from applet bridges arrive here already validated — the - // applet runtime narrows the untrusted tab id and params shape at the trust - // boundary (applet-runtime.ts). A well-formed id for a missing view just - // resolves to the default like any dead URL. - useAppletEvent(workspaceId, 'focusTab', openTab) - - // `moi tabs focus` — a workspace event, not an applet call: the control - // server validated the target and params before publishing. useWorkspaceEvent(event => { - if (event.type === 'tab:focus' && event.workspaceId === workspaceId) { - openTab(event.tab, event.params) - } else if ( + if ( event.type === 'view:deleted' && event.workspaceId === workspaceId && activeTab === viewTabId(event.name) @@ -660,7 +658,9 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro aria-hidden={annotationControls.active || undefined} className="flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden bg-background transition-colors duration-100 ease-out motion-reduce:transition-none" > - {activeTab === 'agent' ? ( + {isUnavailable ? ( + openTab('overview')} /> + ) : activeTab === 'agent' ? ( tabbedChat ) : activeTab === 'overview' ? ( +
@@ -767,7 +771,10 @@ export function WorkspaceScreen({ widgets, views, builders }: WorkspaceScreenPro ) return ( -
+
{hasWorkspaceContent && canUseSplit && splitLayoutConstraints ? ( diff --git a/client/features/workspace/tab-resolution.test.ts b/client/features/workspace/tab-resolution.test.ts index e22ad3fb..526d0426 100644 --- a/client/features/workspace/tab-resolution.test.ts +++ b/client/features/workspace/tab-resolution.test.ts @@ -4,7 +4,6 @@ import type { ViewBuilder, ViewInfo, WorkspaceTabsState } from '@/lib/types' import { effectiveOpenTabs, - isStaleTabLink, normalizeTabsState, resolveActiveTab, tabAvailable @@ -83,9 +82,9 @@ describe('resolveActiveTab', () => { expect(resolveActiveTab('scratchpad', state, views, [], false)).toBe('scratchpad') }) - test('an unavailable URL tab falls back to the default', () => { - expect(resolveActiveTab('view:gone', state, views, [], false)).toBe('overview') - expect(resolveActiveTab('view-builder:b2', state, views, [], false)).toBe('overview') + test('an unavailable explicit destination stays selected for recovery', () => { + expect(resolveActiveTab('view:gone', state, views, [], false)).toBe('view:gone') + expect(resolveActiveTab('view-builder:b2', state, views, [], false)).toBe('view-builder:b2') }) test('an unavailable saved default falls back to the first surviving tab', () => { @@ -108,32 +107,3 @@ describe('resolveActiveTab', () => { expect(resolveActiveTab('view:orders', state, views, [], true)).toBe('view:orders') }) }) - -// Which lost redirects are worth a line in `moi debug logs`. The cost of a -// false positive is an agent chasing a link that was never broken. -describe('isStaleTabLink', () => { - test('a view tab that lost its redirect is stale — the view is gone', () => { - expect(isStaleTabLink('view:gone')).toBe(true) - }) - - test('a segment that is not a tab id at all is stale', () => { - expect(isStaleTabLink('nonsense')).toBe(true) - expect(isStaleTabLink('view:multi/segment')).toBe(true) - }) - - test('a bare workspace URL names nothing, so it is not a broken link', () => { - expect(isStaleTabLink('')).toBe(false) - expect(isStaleTabLink(null)).toBe(false) - expect(isStaleTabLink(undefined)).toBe(false) - }) - - test('the agent tab is never stale — split mode redirects it by design', () => { - expect(isStaleTabLink('agent')).toBe(false) - expect(isStaleTabLink('overview')).toBe(false) - expect(isStaleTabLink('scratchpad')).toBe(false) - }) - - test('a builder tab is never stale — it is swapped for its view when built', () => { - expect(isStaleTabLink('view-builder:b1')).toBe(false) - }) -}) diff --git a/client/features/workspace/tab-resolution.ts b/client/features/workspace/tab-resolution.ts index 6d6000ad..52d90002 100644 --- a/client/features/workspace/tab-resolution.ts +++ b/client/features/workspace/tab-resolution.ts @@ -1,9 +1,9 @@ // Pure tab-state derivation for the workspace screen. The URL is the live -// truth for the active tab (`/workspace/:id/`); the persisted layout +// truth for the active tab (`/workspace/:id/views/`, etc.); the persisted layout // keeps the open set and the saved DEFAULT tab (`tabs.active`). These helpers // turn (URL segment + layout + what actually exists) into the rendered state. import type { ViewBuilder, ViewInfo, WorkspaceTabId, WorkspaceTabsState } from '@/lib/types' -import { parseWorkspaceTab, viewBuilderIdFromTab, viewIdFromTab } from '@/lib/workspace-tabs' +import { viewBuilderIdFromTab, viewIdFromTab } from '@/lib/workspace-tabs' import { createDefaultWorkspaceTabs, normalizeWorkspaceTabs } from '@/lib/workspace-layout' const DEFAULT_TABS = createDefaultWorkspaceTabs() @@ -33,25 +33,9 @@ export function effectiveOpenTabs( return open.length > 0 ? open : DEFAULT_TABS.open } -// Whether a URL segment that lost its redirect is worth reporting as a stale -// link (see the journaling in useWorkspaceNavigation). Only two shapes qualify: -// a view tab — `resolveActiveTab` honors every view that exists, so losing the -// redirect means it is gone — and a segment that isn't a tab id at all. The two -// deliberate exclusions are the routine redirects, not broken links: the agent -// tab bounces whenever split mode docks it as a column, and a view-builder tab -// bounces the moment its build finishes and the screen swaps in the real view. -export function isStaleTabLink(urlTab: string | null | undefined): urlTab is string { - if (!urlTab) return false - const requested = parseWorkspaceTab(urlTab) - return requested === null || viewIdFromTab(requested) !== null -} - -// The active tab for a URL-requested tab id: the request wins when it names an -// available tab (a requested tab missing from the open set is honored — the -// screen auto-adds it, like openTab does), EXCEPT the agent tab in split mode, -// which is the docked column there, not a workspace tab. Anything else — bare -// URL, unknown or unavailable tab — resolves to the saved default run through -// the same availability fallbacks as before. +// An explicit URL keeps its destination, including a missing view so the host +// can show recovery and report the correct address to the agent. Only a bare +// URL or the singleton chat hidden by split mode uses the saved default. export function resolveActiveTab( requested: WorkspaceTabId | null, tabs: WorkspaceTabsState, @@ -59,11 +43,7 @@ export function resolveActiveTab( builders: ViewBuilder[], split: boolean ): WorkspaceTabId { - if ( - requested !== null && - tabAvailable(requested, views, builders) && - !(split && requested === 'agent') - ) { + if (requested !== null && !(split && requested === 'agent')) { return requested } const open = effectiveOpenTabs(tabs, views, builders) diff --git a/client/features/workspace/useWorkspaceNavigation.ts b/client/features/workspace/useWorkspaceNavigation.ts index da77f744..88f77964 100644 --- a/client/features/workspace/useWorkspaceNavigation.ts +++ b/client/features/workspace/useWorkspaceNavigation.ts @@ -1,79 +1,53 @@ -// The workspace's tab address: which tab the URL names, how to navigate -// elsewhere, and the persistence that keeps a bare `/workspace/:id` landing -// somewhere sensible. The URL is the live truth for the active tab; the -// persisted layout keeps the open set and the saved DEFAULT (`tabs.active`). -// -// Everything that merely reacts to navigation stays with the screen: tab-bar -// policy (close, reorder, availability pruning), view-builder lifecycle, the -// applet-runtime `focusTab` subscription, and the `moi tabs focus` subscription. -// They all route through the `navigateToTab` returned here, so every origin — -// tab click, applet, CLI — shares one code path. import { useCallback, useEffect, useMemo } from 'react' +import type { MouseEvent } from 'react' +import { useLocation, useParams, useRouter, useSearch } from 'wouter' -import { useLocation, useParams } from 'wouter' -import { useHistoryState } from 'wouter/use-browser-location' - +import { toast } from '@/client/components/ui/toast' import { reportAppletError } from '@/client/features/applets/applet-log' -import { - isStaleTabLink, - normalizeTabsState, - resolveActiveTab -} from '@/client/features/workspace/tab-resolution' -import { useWorkspaceLayoutCtx } from '@/client/features/workspace/WorkspaceLayoutContext' +import { useAppletEvent } from '@/client/features/applets/applet-runtime' +import { normalizeTabsState, resolveActiveTab, tabAvailable } from './tab-resolution' +import { useWorkspaceLayoutCtx } from './WorkspaceLayoutContext' import { useLatestRef } from '@/client/lib/use-latest-ref' +import { useNavigationClient } from '@/client/runtime/useWorkspaceEvents' import type { ViewBuilder, ViewInfo, WorkspaceTabId, WorkspaceTabsState } from '@/lib/types' +import { parseWorkspaceTab } from '@/lib/workspace-tabs' import { - parseWorkspaceTab, - readAppletParams, - viewIdFromTab, - workspaceTabPath -} from '@/lib/workspace-tabs' + addressPath, + canonicalSearch, + parseMoiHref, + readViewParams, + resolveWorkspaceHref, + tabFromPath, + workspacePath +} from '@/lib/navigation' +type NavigationOptions = { replace?: boolean } -type UseWorkspaceNavigationOptions = { - // What exists right now — a URL naming anything else falls back to the - // default, the same way a stale bookmark does. - views: ViewInfo[] - builders: ViewBuilder[] - // Split mode docks the chat in its own column, so the agent tab is not a - // navigable tab there. - split: boolean -} +// A convenience for returning to tabs, never a second source of active state. +// Memory-only, and scoped by workspace so switching workspaces cannot leak params. +const rememberedAddresses = new Map>() + +type UseWorkspaceNavigationOptions = { views: ViewInfo[]; builders: ViewBuilder[]; split: boolean } export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceNavigationOptions) { const { layout, setLayout, workspaceId } = useWorkspaceLayoutCtx() const [, navigate] = useLocation() - // The tab id is the route's wildcard segment, read from the matched route - // instead of threaded down as a prop — so the pattern stays in AppRouter and - // can't drift from a second copy here. - const urlTab = useParams()['*'] ?? null - // Applet params ride navigation state; anything malformed reads as {}. A - // stateless navigation (plain tab click) clears it, so views mount empty. - const historyState = useHistoryState() - const appletParams = useMemo(() => readAppletParams(historyState), [historyState]) - + const { base } = useRouter() + const path = useParams()['*'] ?? '' + const search = canonicalSearch(useSearch()) + const appletParams = useMemo(() => readViewParams(search), [search]) const tabsState = normalizeTabsState(layout.tabs) - // Mirror for the effects below: a debounced layout PUT can still be in flight - // when a `workspace:updated` refetch lands, so reading the render-time value - // could persist a stale open set (and resurrect a just-closed tab). const tabsStateRef = useLatestRef(tabsState) - - const requestedTab = parseWorkspaceTab(urlTab) - const activeTab = resolveActiveTab(requestedTab, tabsState, views, builders, split) - const urlTabHonored = requestedTab !== null && requestedTab === activeTab - - // Every tab switch is a replace-navigation — never push, so Back leaves the - // workspace instead of walking tab history. - const navigateToTab = useCallback( - (tab: WorkspaceTabId, params?: Record) => { - navigate(workspaceTabPath(workspaceId, tab), { - replace: true, - // Only a focus navigation carries params; omitting the key clears - // history state, which is how a plain tab click resets them. - ...(params ? { state: { appletParams: params } } : {}) - }) - }, - [navigate, workspaceId] - ) + const remembered = useMemo(() => { + let entries = rememberedAddresses.get(workspaceId) + if (!entries) rememberedAddresses.set(workspaceId, (entries = new Map())) + return entries + }, [workspaceId]) + const requestedTab = tabFromPath(path) + const legacyTab = requestedTab ? null : parseWorkspaceTab(path) + const activeTab = resolveActiveTab(requestedTab ?? legacyTab, tabsState, views, builders, split) + const isUnavailable = + Boolean(path) && !legacyTab && (!requestedTab || !tabAvailable(requestedTab, views, builders)) + const honored = requestedTab === activeTab && !isUnavailable const setTabs = useCallback( (tabs: WorkspaceTabsState) => { @@ -83,46 +57,131 @@ export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceN [setLayout, tabsStateRef] ) - // Keep the URL honest. One redirect covers every case: a bare - // /workspace/:id, an unknown or dead tab, and the agent tab while split mode - // hides it. + const go = useCallback( + (target: string, options: NavigationOptions = {}) => { + const current = window.location.pathname + canonicalSearch(window.location.search) + const absolute = `${base}${target}` + if (current !== absolute || window.location.hash) navigate(target, options) + }, + [base, navigate] + ) + + const navigateToTab = useCallback( + (tab: WorkspaceTabId, options: NavigationOptions = {}) => { + go(remembered.get(tab) ?? addressPath(workspaceId, { tab, search: '' }), options) + }, + [go, remembered, workspaceId] + ) + + const navigateHref = useCallback( + (href: string) => { + if (!href.startsWith('moi:')) { + const target = resolveWorkspaceHref(workspaceId, href, base) + window.location.assign(target) + return + } + const address = parseMoiHref(href) + if (!tabAvailable(address.tab, views, builders)) + throw new Error('This destination is unavailable in this workspace') + go(addressPath(workspaceId, address)) + }, + [base, builders, go, views, workspaceId] + ) + + const reportError = useCallback( + (error: unknown) => { + const message = error instanceof Error ? error.message : 'Navigation failed' + toast.add({ type: 'error', title: 'Could not navigate', description: message }) + reportAppletError(workspaceId, { source: 'runtime', message }) + }, + [workspaceId] + ) + + useAppletEvent(workspaceId, 'navigate', href => { + try { + navigateHref(href) + } catch (error) { + reportError(error) + } + }) + useNavigationClient(workspaceId, navigateHref) + + // Bare workspace URLs, old bookmarks, and hidden singleton chat routes are + // the only redirects. Missing destinations keep their URL and show recovery. useEffect(() => { - if (urlTab === activeTab) return - // A URL that named a view and didn't get it is a stale link — a deleted - // view, an old bookmark, a `focusTab` the agent wrote against a view it - // later renamed. The agent can't see the redirect happen, so journal it - // for `moi debug logs`. - if (isStaleTabLink(urlTab)) reportDeadTab(workspaceId, urlTab, activeTab) - navigateToTab(activeTab) - }, [activeTab, navigateToTab, urlTab, workspaceId]) + if (isUnavailable) return + if (legacyTab) { + go(addressPath(workspaceId, { tab: legacyTab, search }), { replace: true }) + } else if (!path || (requestedTab === 'agent' && split)) { + navigateToTab(activeTab, { replace: true }) + } + }, [ + activeTab, + go, + legacyTab, + navigateToTab, + path, + requestedTab, + search, + split, + isUnavailable, + workspaceId + ]) - // Navigating IS the tab switch, so persist its effects through the same write - // path as before: the saved default follows the URL, and a URL-navigated tab - // missing from the open set is auto-added. Only an honored URL writes — - // redirects settle into an honored URL first, which is what stops this and - // the redirect effect above from ping-ponging. useEffect(() => { - if (!urlTabHonored) return + if (!honored) return + remembered.set(activeTab, addressPath(workspaceId, { tab: activeTab, search })) const current = tabsStateRef.current const open = current.open.includes(activeTab) ? current.open : [...current.open, activeTab] - if (open === current.open && current.active === activeTab) return - setTabs({ open, active: activeTab }) - }, [activeTab, setTabs, tabsStateRef, urlTabHonored]) + if (open !== current.open || current.active !== activeTab) setTabs({ open, active: activeTab }) + }, [activeTab, honored, remembered, search, setTabs, tabsStateRef, workspaceId]) - return { tabsState, activeTab, appletParams, navigateToTab, setTabs } -} + // React bubbling includes applet/chat portals. Native modified clicks and + // downloads keep a real href, so the browser can handle them normally. + const onNavigationClick = useCallback( + (event: MouseEvent) => { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.altKey || + event.shiftKey + ) + return + const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null + if ( + !(anchor instanceof HTMLAnchorElement) || + anchor.hasAttribute('download') || + (anchor.target && anchor.target !== '_self') + ) + return + const url = new URL(anchor.href) + const prefix = workspacePath(workspaceId, base) + '/' + if (url.origin !== window.location.origin || !url.pathname.startsWith(prefix)) return + // In-page fragments continue using native browser behavior. + if (url.hash) return + const tab = tabFromPath(url.pathname.slice(prefix.length)) + if (!tab) return + event.preventDefault() + try { + if (!tabAvailable(tab, views, builders)) + throw new Error('This destination is unavailable in this workspace') + go(addressPath(workspaceId, { tab, search: canonicalSearch(url.search) })) + } catch (error) { + reportError(error) + } + }, + [base, builders, go, reportError, views, workspaceId] + ) -// Journal a stale link (see `isStaleTabLink` for which URLs qualify). -// Attributed to the view when the dead id names one — that's the file the agent -// would fix, and the entry then clears the moment a view by that name builds. -// A malformed segment names no applet and lands unattributed. The journal's own -// dedup keeps a redirect loop to a single line. -function reportDeadTab(workspaceId: string, urlTab: string, activeTab: WorkspaceTabId): void { - const requested = parseWorkspaceTab(urlTab) - const viewId = requested ? viewIdFromTab(requested) : null - reportAppletError(workspaceId, { - source: 'runtime', - ...(viewId ? { kind: 'view' as const, name: viewId } : {}), - message: `Tab "${urlTab}" does not exist in this workspace — the URL redirected to "${activeTab}". A link, bookmark, or focusTab call is pointing at a tab that was deleted or renamed.` - }) + return { + tabsState, + activeTab, + appletParams, + navigateToTab, + setTabs, + isUnavailable, + onNavigationClick + } } diff --git a/client/runtime/useWorkspaceEvents.ts b/client/runtime/useWorkspaceEvents.ts index 5cbd6732..bf1bac48 100644 --- a/client/runtime/useWorkspaceEvents.ts +++ b/client/runtime/useWorkspaceEvents.ts @@ -1,4 +1,5 @@ import { useEffect } from 'react' +import type { NavigationRequest } from '@/lib/navigation' import { useLatestRef } from '@/client/lib/use-latest-ref' import { wsUrl } from '@/client/lib/ws-url' @@ -8,8 +9,7 @@ import type { HarnessAvailability, ViewBuilder, ViewInfo, - WidgetInfo, - WorkspaceTabId + WidgetInfo } from '@/lib/types' export type WorkspaceEvent = @@ -46,14 +46,7 @@ export type WorkspaceEvent = // App settings changed (PATCH /api/settings from any client) — carries the // new value so caches update without a refetch. | { type: 'settings:updated'; settings: AppSettings } - // `moi tabs focus` — every open client of `workspaceId` navigates (replace) - // to `tab`, delivering `params` to the target view via navigation state. - | { - type: 'tab:focus' - workspaceId: string - tab: WorkspaceTabId - params?: Record - } + | NavigationRequest type WorkspaceEventHandler = (event: WorkspaceEvent) => void @@ -89,6 +82,7 @@ function ensureConnection() { ws = socket connecting = false reconnectAttempt = 0 + sendNavigationPresence() if (everConnected) for (const handler of reconnectListeners) handler() everConnected = true } @@ -142,3 +136,53 @@ export function useWorkspaceEvent(handler: WorkspaceEventHandler) { } }, [handlerRef]) } + +let navigationWorkspace: string | null = null + +function sendNavigationPresence() { + if (ws?.readyState !== WebSocket.OPEN) return + ws.send( + JSON.stringify({ + type: 'navigation:presence', + workspaceId: navigationWorkspace, + focused: document.visibilityState === 'visible' && document.hasFocus() + }) + ) +} + +// This hook owns presence for the displayed workspace, including reconnects. +// Requests received after a workspace switch never act on the new workspace. +export function useNavigationClient(workspaceId: string, navigate: (href: string) => void) { + useEffect(() => { + navigationWorkspace = workspaceId + sendNavigationPresence() + window.addEventListener('focus', sendNavigationPresence) + document.addEventListener('visibilitychange', sendNavigationPresence) + return () => { + navigationWorkspace = null + sendNavigationPresence() + window.removeEventListener('focus', sendNavigationPresence) + document.removeEventListener('visibilitychange', sendNavigationPresence) + } + }, [workspaceId]) + + useWorkspaceEvent(event => { + if (event.type !== 'navigation:request' || event.workspaceId !== workspaceId) return + const socket = ws + try { + if (navigationWorkspace !== workspaceId) throw new Error('The browser switched workspaces.') + navigate(event.href) + socket?.send( + JSON.stringify({ type: 'navigation:result', requestId: event.requestId, ok: true }) + ) + } catch (error) { + socket?.send( + JSON.stringify({ + type: 'navigation:result', + requestId: event.requestId, + error: error instanceof Error ? error.message : 'Navigation failed' + }) + ) + } + }) +} diff --git a/docs/applet-assets.md b/docs/applet-assets.md index 130ce06d..ae9bf417 100644 --- a/docs/applet-assets.md +++ b/docs/applet-assets.md @@ -134,7 +134,8 @@ by `moi init`: declare module 'moi' { // required: virtual module, Bun won't type it export function fileUrl(path: string): string - export function focusTab(tab: string, params?: Record): void + export function navigate(href: string): void + export function resolveHref(href: string): string export type WidgetConfig = { colSpan: 1 | 2 | 3 | 4 rowSpan: 1 | 2 | 3 | 4 diff --git a/docs/navigation.md b/docs/navigation.md new file mode 100644 index 00000000..835b364b --- /dev/null +++ b/docs/navigation.md @@ -0,0 +1,52 @@ +# Workspace navigation + +Portable addresses identify destinations inside the current workspace: + +- `moi:/overview` +- `moi:/scratchpad` +- `moi:/views/events?eventId=123` + +The host resolves these to `/workspace//views/events?eventId=123`. Domain and deployment +prefix belong to the host adapter in `lib/navigation.ts`. Internal tab IDs (`view:events`) and +persisted layouts are unchanged. Old browser view URLs replace-redirect to the new paths. +The singleton agent and view-builder tabs remain host-internal routes. + +## One controller + +Applet `navigate(href)`, tab selection, resolved anchor clicks, and CLI requests use +`useWorkspaceNavigation`. `resolveHref(href)` gives applets a native browser href; chat Markdown +uses the same resolver. Ordinary modified clicks, downloads, targets, and web links keep browser +behavior. The Markdown sanitizer only admits valid moi navigation links, never moi image URLs. + +The browser URL owns the active destination and params. Query values are strings, read with +`URLSearchParams.get()` (the first value for repeated keys), and views parse their own types. +Canonical query serialization sorts keys and preserves repeated values. +A view's detail UI must render from params and navigate when selection changes. There is no +history.state payload or two-way effect synchronizing local selection. + +Navigation pushes history. Each workspace remembers its tabs' +last addresses in browser memory. A tab click restores its address; an explicit link names the +exact state to open. Only the active URL survives reload. Parked views retain their own params. +Layout persistence still stores tab order and the default tab, without query strings. + +Invalid action requests do not navigate. An unavailable direct browser address stays in the URL +and shows recovery to Overview. `fileUrl()` remains the resource URL API. + +## CLI transport + +`moi tabs` lists addresses. `moi navigate
` validates the destination, then +uses the existing events WebSocket to address one browser. Each browser reports its displayed +workspace and focus. Server arrival order chooses the most recently focused connected browser +showing that workspace, even after focus moves to a terminal. A sole client needs no focus record; +multiple clients without a focus record require the user to focus one first. + +The browser acknowledges after applying the URL, without waiting for view data. Only the addressed +socket may settle its request. Disconnects and workspace switches fail pending requests. The +five-second timeout does not retry: navigation may already have happened. + +## Migration + +`focusTab`, `moi tabs focus`, and `tab:focus` have been removed. Update applet sources to portable +addresses and rebuild. Update installed workspace guidance/types through `moi skill update`. +No workspace layout migration is needed. This change does not implement full deployment-prefix +support for unrelated assets and API endpoints. diff --git a/docs/rfc-intents-v2.md b/docs/rfc-intents-v2.md index 08ac55bb..99f1b0bc 100644 --- a/docs/rfc-intents-v2.md +++ b/docs/rfc-intents-v2.md @@ -1,3 +1,5 @@ +> Historical RFC. Navigation is superseded by [Workspace navigation](navigation.md). + # RFC: workspace tab navigation and applet messaging (intents v2) Status: MVP 1 (tab foundation) and MVP 2 (chat messaging) implemented; MVP 3 skill guidance diff --git a/lib/moi-context.ts b/lib/moi-context.ts index f795a235..a579c575 100644 --- a/lib/moi-context.ts +++ b/lib/moi-context.ts @@ -54,7 +54,7 @@ export type MoiContext = { // back to the id when unset; so does the envelope. tabTitle?: string // The params the active view is rendering with right now, straight from - // navigation state. The emitter side of the same contract (`focusTab`) sets + // URL query strings. The emitter side of the same contract (`navigate`) sets // them, so the agent sees a view's addressable state in both directions. // Absent for tabs that take no params (overview, scratchpad, agent). tabParams?: Record diff --git a/lib/navigation.test.ts b/lib/navigation.test.ts new file mode 100644 index 00000000..8563b0f6 --- /dev/null +++ b/lib/navigation.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from 'bun:test' +import { + addressPath, + canonicalSearch, + moiHref, + parseMoiHref, + readViewParams, + resolveWorkspaceHref, + tabFromPath, + tabPath +} from './navigation' + +describe('workspace addresses', () => { + test('round trips a destination independently of workspace, origin, and deployment prefix', () => { + const href = 'moi:/views/events?eventId=123' + const address = parseMoiHref(href) + expect(moiHref(address.tab, address.search)).toBe(href) + expect(addressPath('abc', address)).toBe('/workspace/abc/views/events?eventId=123') + expect(addressPath('other', address, '/prefix/')).toBe( + '/prefix/workspace/other/views/events?eventId=123' + ) + expect(resolveWorkspaceHref('abc', href)).toBe('/workspace/abc/views/events?eventId=123') + }) + test('view filenames with dots, Unicode, and encoded spaces remain addressable', () => { + expect(parseMoiHref('moi:/views/my.events').tab).toBe('view:my.events') + expect(parseMoiHref('moi:/views/Gr%C3%BC%C3%9Fe%20events').tab).toBe('view:Grüße events') + expect(tabPath('view:Grüße events')).toBe('views/Gr%C3%BC%C3%9Fe%20events') + }) + + test('query values remain strings and follow URLSearchParams.get semantics', () => { + const values = { title: 'Grüße & a/b?c=#100% +', enabled: 'false', page: '02', empty: '' } + const href = `moi:/views/events?${new URLSearchParams(values)}` + expect(readViewParams(parseMoiHref(href).search)).toEqual(values) + expect(readViewParams('?x=1&x=2')).toEqual({ x: '1' }) + expect(readViewParams('?x=&x=2')).toEqual({ x: '' }) + expect(canonicalSearch('?b=2&a=1&b=3')).toBe('?a=1&b=2&b=3') + expect(readViewParams(parseMoiHref('moi:/views/events?x=1&x=2').search)).toEqual({ x: '1' }) + expect(readViewParams('')).toEqual({}) + expect(Object.hasOwn(readViewParams('__proto__=safe'), '__proto__')).toBe(true) + }) + test('rejects ambiguous, malformed, private, and unsupported destinations', () => { + for (const href of [ + null, + 2, + '', + '/views/events', + 'view:events', + 'moi://views/events', + 'moi:/views/', + 'moi:/views/a/b', + 'moi:/views/%2f', + 'moi:/views/%', + 'moi:/views/../overview', + 'moi:/views/a#part', + 'moi:/agent', + 'moi:/view-builders/a', + 'moi:/chats/a', + 'moi:/files/a.md' + ]) { + expect(() => parseMoiHref(href)).toThrow() + } + }) + test('host-only tabs still round trip without becoming portable destinations', () => { + for (const tab of [ + 'agent', + 'overview', + 'scratchpad', + 'view:events', + 'view-builder:abc' + ] as const) { + expect(tabFromPath(tabPath(tab))).toBe(tab) + } + }) + test('web hrefs stay web hrefs; executable protocols cannot use the API', () => { + expect(resolveWorkspaceHref('abc', 'https://example.com/a?q=b')).toBe( + 'https://example.com/a?q=b' + ) + expect(() => resolveWorkspaceHref('abc', 'javascript:alert(1)')).toThrow() + expect(() => resolveWorkspaceHref('abc', 'data:text/html,hello')).toThrow() + }) +}) diff --git a/lib/navigation.ts b/lib/navigation.ts new file mode 100644 index 00000000..ecb55b12 --- /dev/null +++ b/lib/navigation.ts @@ -0,0 +1,84 @@ +import type { WorkspaceTabId } from './types' + +export type ViewParams = Record +export type WorkspaceAddress = { tab: WorkspaceTabId; search: string } + +// Public destinations deliberately exclude the current singleton chat and +// transient builders. +export function parseMoiHref(href: unknown): WorkspaceAddress { + if (typeof href !== 'string' || !href.startsWith('moi:/') || href.startsWith('moi://')) { + throw new Error('Expected a workspace address such as moi:/views/events?eventId=123') + } + const path = href.slice(5).split(/[?#]/, 1)[0] + if (href.includes('#') || /[\s\\]/.test(path)) throw new Error('Invalid workspace address') + const tab = tabFromPath(path) + if (!tab || tab === 'agent' || tab.startsWith('view-builder:')) { + throw new Error(`Unsupported workspace destination: ${path}`) + } + const query = href.indexOf('?') + const search = query < 0 ? '' : canonicalSearch(href.slice(query + 1)) + return { tab, search } +} + +export function canonicalSearch(search: string): string { + // Sorting makes equivalent addresses a no-op instead of adding history entries. + const params = new URLSearchParams(search) + params.sort() + const result = params.toString() + return result ? `?${result}` : '' +} + +export function readViewParams(search: string): ViewParams { + const params = new URLSearchParams(search) + return Object.fromEntries(Array.from(params.keys(), key => [key, params.get(key)!])) +} + +export function tabPath(tab: WorkspaceTabId): string { + if (tab.startsWith('view:')) return `views/${encodeURIComponent(tab.slice(5))}` + if (tab.startsWith('view-builder:')) return `view-builders/${encodeURIComponent(tab.slice(13))}` + return tab +} + +export function tabFromPath(path: string): WorkspaceTabId | null { + if (path === 'overview' || path === 'scratchpad' || path === 'agent') return path + const match = /^(views|view-builders)\/([^/]+)$/.exec(path) + if (!match) return null + try { + const id = decodeURIComponent(match[2]) + // eslint-disable-next-line no-control-regex -- URL path IDs must reject control characters. + if (id === '.' || id === '..' || /[/\\\u0000-\u001f\u007f]/.test(id)) return null + return match[1] === 'views' ? `view:${id}` : `view-builder:${id}` + } catch { + return null + } +} + +export function moiHref(tab: WorkspaceTabId, search = ''): string { + return `moi:/${tabPath(tab)}${canonicalSearch(search)}` +} + +// Deployment-specific addressing is confined to this host adapter. Callers +// may supply the router base; portable links never contain it. +export function workspacePath(workspaceId: string, base = ''): string { + return `${base.replace(/\/$/, '')}/workspace/${encodeURIComponent(workspaceId)}` +} + +export function addressPath(workspaceId: string, address: WorkspaceAddress, base = ''): string { + return `${workspacePath(workspaceId, base)}/${tabPath(address.tab)}${address.search}` +} + +export function resolveWorkspaceHref(workspaceId: string, href: string, base = ''): string { + if (href.startsWith('moi:')) return addressPath(workspaceId, parseMoiHref(href), base) + // Only explicit ordinary web addresses may leave the workspace through the + // imperative API. Native anchors keep their existing protocol policy. + const url = new URL(href) + if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('Unsupported URL') + return url.href +} + +export type NavigationRequest = { + type: 'navigation:request' + requestId: string + workspaceId: string + href: string +} diff --git a/lib/types.ts b/lib/types.ts index 734d301b..0b04df68 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -71,7 +71,7 @@ export type ViewBuilder = { // server-side (bundle pipeline, RPC route); `load`/`render`/`window`/`runtime` // are browser-side and reach the journal via POST // /api/workspaces/:id/applet-log. `runtime` is the applet API refusing a -// request rather than code throwing — a `focusTab` at a tab that no longer +// request rather than code throwing — a `navigate` to a view that no longer // exists, a `sendChatMessage` dropped by the rate limit — which the applet's // own code never sees. export type AppletLogSource = 'build' | 'load' | 'render' | 'window' | 'rpc' | 'runtime' diff --git a/lib/workspace-tabs.test.ts b/lib/workspace-tabs.test.ts index 4092219f..e2ba92b3 100644 --- a/lib/workspace-tabs.test.ts +++ b/lib/workspace-tabs.test.ts @@ -4,7 +4,6 @@ import { isParamsRecord, isWorkspaceTabId, parseWorkspaceTab, - readAppletParams, viewBuilderIdFromTab, viewBuilderTabId, viewIdFromTab, @@ -55,7 +54,7 @@ describe('parseWorkspaceTab', () => { describe('workspaceTabPath', () => { test('builds the tab URL', () => { - expect(workspaceTabPath('ws1', 'view:roadmap')).toBe('/workspace/ws1/view:roadmap') + expect(workspaceTabPath('ws1', 'view:roadmap')).toBe('/workspace/ws1/views/roadmap') expect(workspaceTabPath('ws1', 'agent')).toBe('/workspace/ws1/agent') }) }) @@ -89,18 +88,3 @@ describe('isParamsRecord', () => { expect(isParamsRecord(undefined)).toBe(false) }) }) - -describe('readAppletParams', () => { - test('reads params out of navigation state', () => { - expect(readAppletParams({ appletParams: { order: 'o-1' } })).toEqual({ order: 'o-1' }) - }) - - test('degrades to {} for anything malformed', () => { - expect(readAppletParams(null)).toEqual({}) - expect(readAppletParams(undefined)).toEqual({}) - expect(readAppletParams({})).toEqual({}) - expect(readAppletParams({ appletParams: [1] })).toEqual({}) - expect(readAppletParams({ appletParams: 'x' })).toEqual({}) - expect(readAppletParams('state')).toEqual({}) - }) -}) diff --git a/lib/workspace-tabs.ts b/lib/workspace-tabs.ts index c3e12a5f..f10b2886 100644 --- a/lib/workspace-tabs.ts +++ b/lib/workspace-tabs.ts @@ -1,8 +1,6 @@ -// Workspace tab addressing, shared by the client router, the control server, -// and the CLI. A tab id doubles as the URL suffix of `/workspace/:id/` — -// ids are URL-safe as-is (`:` is a legal path character), so building a path -// is plain concatenation and parsing is plain validation. +// Internal tab identifiers stay separate from public navigation addresses. import type { WorkspaceTabId } from './types' +import { addressPath } from './navigation' export function isWorkspaceTabId(value: unknown): value is WorkspaceTabId { return ( @@ -22,7 +20,7 @@ export function parseWorkspaceTab(segment: string | null | undefined): Workspace } export function workspaceTabPath(workspaceId: string, tab: WorkspaceTabId): string { - return `/workspace/${workspaceId}/${tab}` + return addressPath(workspaceId, { tab, search: '' }) } export const viewTabId = (viewId: string): WorkspaceTabId => `view:${viewId}` @@ -32,17 +30,7 @@ export const viewBuilderTabId = (builderId: string): WorkspaceTabId => `view-bui export const viewBuilderIdFromTab = (tab: WorkspaceTabId): string | null => tab.startsWith('view-builder:') ? tab.slice('view-builder:'.length) : null -// The only params shape focusTab / `moi tabs focus` carry: one JSON-plain -// object. Arrays and null are valid JSON but not a params record. +// A record check shared by JSON boundary validators. export function isParamsRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } - -// Applet params as read back from navigation state (`state.appletParams`). -// Anything malformed degrades to `{}` — a view must render with empty params -// anyway (fresh mount, new browser tab, plain tab-bar click). -export function readAppletParams(state: unknown): Record { - if (!isParamsRecord(state)) return {} - const params = (state as { appletParams?: unknown }).appletParams - return isParamsRecord(params) ? params : {} -} diff --git a/server/applets/build-applet.ts b/server/applets/build-applet.ts index 589a328a..9eee1d9e 100644 --- a/server/applets/build-applet.ts +++ b/server/applets/build-applet.ts @@ -125,11 +125,8 @@ export function rpc(module, name) { // per-segment URL-encoded so spaces / unicode in filenames survive. A leading // slash is stripped so both `clips/a.mp4` and `/clips/a.mp4` work. // -// `focusTab(tab, params?)` and `sendChatMessage({ message, attachments? })` forward to -// this bundle's host-attached bridge — client-local replace-navigation to a -// workspace tab (params delivered to the target view via navigation state), -// and a chat message sent to the workspace's active chat as if the user had -// typed it. This virtual module is INLINED PER BUNDLE, +// Navigation, href resolution, and chat intents forward to the bundle's +// host-attached bridge. This virtual module is inlined per bundle, // so `bridge` is private to one applet: the host attaches it right after the // dynamic import and neuters it on invalidation (see // client/features/applets/applet-runtime.ts). Optional-chained so calls no-op @@ -154,8 +151,12 @@ export function fileUrl(path) { return BASE + "/fs/" + clean.split("/").map(encodeURIComponent).join("/"); } -export function focusTab(tab, params) { - bridge?.focusTab(tab, params); +export function navigate(href) { + bridge?.navigate(href); +} + +export function resolveHref(href) { + return bridge?.resolveHref(href) ?? ''; } export function addChatAttachment(input) { @@ -396,8 +397,8 @@ function widgetEntryPlugin(widgetPath: string, syntheticCssPath: string): BunPlu // Surface the bridge wiring on every bundle's `index.js` so the host // can attach after dynamic import. Bun dedupes the `moi` virtual // module within a bundle, so this re-export and the applet's own - // `import { focusTab } from 'moi'` share one module instance — the - // attached bridge is the one focusTab reads. + // `import { navigate } from 'moi'` share one module instance — the + // attached bridge is the one navigate reads. `export { __attachBridge, __getBridge } from "moi";` ].join('\n'), loader: 'js' diff --git a/server/cli.ts b/server/cli.ts index 9d2b2e11..b83b3ef0 100755 --- a/server/cli.ts +++ b/server/cli.ts @@ -22,7 +22,7 @@ import { deriveThemeColors } from '@/lib/themes' import type { AgentTheme, ColorTheme, FontTheme, RadiusTheme } from '@/lib/themes' -import { isParamsRecord } from '@/lib/workspace-tabs' +import { parseMoiHref } from '@/lib/navigation' import type { AppletLogEntry, ScratchArrowEnd, @@ -1844,8 +1844,14 @@ function sendControl( onResult: (res: Record) => void | Promise ) { const ws = new WebSocket(CONTROL_URL) - ws.onopen = () => ws.send(JSON.stringify(payload)) + let connected = false + let responded = false + ws.onopen = () => { + connected = true + ws.send(JSON.stringify(payload)) + } ws.onmessage = async event => { + responded = true const res = JSON.parse(String(event.data)) if (res.error) { console.error('\n' + pc.red('✗') + ' ' + res.error + '\n') @@ -1860,6 +1866,14 @@ function sendControl( ws.close() process.exit(0) } + ws.onclose = () => { + if (connected && !responded) { + console.error( + 'The server disconnected before acknowledging the command. Check the browser before retrying.' + ) + process.exit(1) + } + } ws.onerror = () => void exitControlUnreachable() } @@ -2352,84 +2366,64 @@ const debug = defineCommand({ // The listing behind `moi tabs`: every tab (static + views), one per row, the // saved default (`layout.tabs.active`) marked. The -// output shape is documented in docs/rfc-intents-v2.md §3 — keep them in sync. +// addresses are documented in docs/navigation.md. function runTabsList(dir: string) { const path = resolve(dir) sendControl(path, { type: 'tabs', path }, res => { - type Row = { id: string; title: string; isDefault: boolean } + type Row = { title: string; isDefault: boolean; href?: string } const rows: Row[] = Array.isArray(res.tabs) ? (res.tabs as Row[]) : [] console.log( '\n' + pc.bold('moi tabs') + pc.dim(' — workspace tabs, the default one marked') + '\n' ) console.log( columns( - ['', 'tab', 'title'].map(h => pc.dim(h)), + ['', 'title', 'address'].map(h => pc.dim(h)), rows.map(row => [ row.isDefault ? pc.green('●') : ' ', - row.isDefault ? pc.bold(row.id) : row.id, - row.title + row.isDefault ? pc.bold(row.title) : row.title, + row.href ?? '—' ]) ) ) - console.log( - '\n' + pc.dim(' Focus one: moi tabs focus [--params \'{"k":"v"}\']') + '\n' - ) + console.log('\n' + pc.dim(' Open one: moi navigate
') + '\n') }) } -const tabFocus = defineCommand({ - meta: { name: 'focus', description: 'Focus a workspace tab in every open client' }, +const navigate = defineCommand({ + meta: { + name: 'navigate', + description: 'Navigate the last active browser showing this workspace' + }, args: { - tab: { + href: { type: 'positional', required: true, - description: 'Tab id from `moi tabs`, e.g. view:orders' - }, - params: { - type: 'string', - description: 'One JSON object delivered to the view as its params, e.g. \'{"order":"o-1"}\'' + description: 'Workspace address, e.g. moi:/views/events?eventId=123' }, dir: dirArg }, run({ args }) { - const path = resolve(args.dir) - let params: Record | undefined - if (args.params !== undefined) { - try { - const parsed: unknown = JSON.parse(args.params) - if (!isParamsRecord(parsed)) throw new Error('not a JSON object') - params = parsed - } catch { - console.error( - '\n' + pc.red('✗') + ' --params must be one JSON object, e.g. \'{"order":"o-1"}\'\n' - ) - process.exit(1) - } + try { + parseMoiHref(args.href) + } catch (error) { + console.error(error instanceof Error ? error.message : 'Invalid workspace address') + process.exit(1) } - sendControl( - path, - { type: 'tab:focus', path, tab: args.tab, ...(params ? { params } : {}) }, - res => { - console.log('\n' + pc.green('✓') + ' Focused ' + pc.bold(String(res.tab)) + '\n') - } - ) + const path = resolve(args.dir) + sendControl(path, { type: 'navigate', path, href: args.href }, res => { + console.log('\n' + pc.green('✓') + ' Navigated to ' + pc.bold(String(res.href)) + '\n') + }) } }) -const tabsSubCommands = { focus: tabFocus } - const tabs = defineCommand({ - meta: { - name: 'tabs', - description: 'List workspace tabs, or focus one: `moi tabs focus `' - }, - subCommands: tabsSubCommands, + meta: { name: 'tabs', description: 'List workspace tabs and their navigation addresses' }, args: { dir: dirArg }, - run({ args, rawArgs }) { - // citty invokes the parent run even after dispatching a subcommand — only - // list when none ran (same pattern as `moi env` / `moi skill`). - const sub = rawArgs.find(a => !a.startsWith('-')) - if (sub && Object.hasOwn(tabsSubCommands, sub)) return + run({ args }) { + if (args._.length) { + console.error('moi tabs only lists tabs. Use moi navigate
to navigate.') + process.exit(1) + } runTabsList(args.dir) } }) @@ -3047,6 +3041,7 @@ const workspaceCommands = { scratch, skill, tabs, + navigate, 'ui-components': uiComponents } diff --git a/server/control.ts b/server/control.ts index a756ffba..7845dfd9 100644 --- a/server/control.ts +++ b/server/control.ts @@ -4,7 +4,7 @@ import { resolve } from 'path' import { parseAppletSelector } from '@/lib/applet-selector' import { resolveWorkspaceTheme } from '@/lib/themes' import type { WorkspaceEntry } from '@/lib/types' -import { isParamsRecord } from '@/lib/workspace-tabs' +import { navigationRelay } from './navigation-relay' import { clearAppletLog, getAppletLog, getAppletLogCount } from './applet-log' import { serializeWorkspaceBundle } from './bundle-queue' @@ -19,7 +19,7 @@ import { executeScratchOp } from './scratchpad-executor' import { readScratchpadImage, readScratchpadShapes } from './scratchpad' import { relayScratchOp } from './scratchpad-relay' import { broadcastAll } from './state' -import { assembleTabRows, resolveFocusTab } from './tabs' +import { assembleTabRows, resolveNavigation } from './tabs' import { applyThemeUpdate } from './theme' import { handleBundle } from './widgets' import { getViewList, handleBundleViews, hasViewId } from './views' @@ -311,33 +311,27 @@ export const control = Bun.serve({ return } - // `moi tabs focus ` — validate the target, then publish a - // workspace-scoped `tab:focus` event. Every connected client of that - // workspace navigates (replace) with the params in navigation state. - if (data.type === 'tab:focus') { + // CLI navigation is addressed to one live browser and acknowledged. + if (data.type === 'navigate') { const match = await resolveWorkspace(ws, data.path) if (!match) return - const resolved = await resolveFocusTab(data.tab, { - hasView: viewId => hasViewId(match.path, viewId), - viewList: () => getViewList(match.path) + const resolved = await resolveNavigation(data.href, { + hasView: viewId => hasViewId(match.path, viewId) }) if (!resolved.ok) { ws.send(JSON.stringify({ error: resolved.error })) return } - // The CLI already validated --params as one JSON object; re-check the - // shape here so a hand-rolled control client can't publish garbage. - if (data.params !== undefined && !isParamsRecord(data.params)) { - ws.send(JSON.stringify({ error: 'Params must be one JSON object' })) - return + try { + await navigationRelay.navigate(match.id, resolved.href) + ws.send(JSON.stringify({ ok: true, href: resolved.href })) + } catch (error) { + ws.send( + JSON.stringify({ + error: error instanceof Error ? error.message : 'Navigation failed' + }) + ) } - publishEvent({ - type: 'tab:focus', - workspaceId: match.id, - tab: resolved.tab, - ...(data.params !== undefined ? { params: data.params } : {}) - }) - ws.send(JSON.stringify({ ok: true, tab: resolved.tab })) return } diff --git a/server/moi-scaffold.ts b/server/moi-scaffold.ts index 2114bd45..f023615b 100644 --- a/server/moi-scaffold.ts +++ b/server/moi-scaffold.ts @@ -98,10 +98,11 @@ declare module 'moi' { // Absolute URL to a workspace file, streamed by the server. Pass a // workspace-relative path (e.g. 'clips/001.mp4'). Media/asset files only. export function fileUrl(path: string): string - // Switch the workspace to a tab (replace navigation). \`params\` reach the - // target view as its \`params\` prop — JSON-plain values only. No-ops outside - // the moi host. Tab ids: 'overview' | 'scratchpad' | 'view:'. - export function focusTab(tab: string, params?: Record): void + // Navigate within this workspace. Query strings are delivered as view params. + // Navigation adds a browser history entry. Include view params in the URL. + export function navigate(href: string): void + // Resolve a portable moi:/ address to a real browser href for an anchor. + export function resolveHref(href: string): string export type AttachmentInput = | { type: 'text'; label: string; text: string } | { type: 'file'; file: File; path?: never } @@ -133,7 +134,7 @@ declare module '*.svg' { const s: string; export default s } // Write `.moi/applet-env.d.ts` from the template this CLI ships, overwriting // whatever is there. The file is auto-generated and declares the `moi` module's // public API, so it drifts the moment the CLI grows an applet-facing function -// (`focusTab`, `sendChatMessage`) while a workspace keeps the copy written at +// (`navigate`, `sendChatMessage`) while a workspace keeps the copy written at // `moi init` time — leaving the agent's editor and `tsc` insisting a real API // doesn't exist. Regenerated alongside skills, the other agent-facing contract // moi ships. Refreshes an existing `.moi/` only — never creates one, so a diff --git a/server/navigation-relay.test.ts b/server/navigation-relay.test.ts new file mode 100644 index 00000000..86ba70e2 --- /dev/null +++ b/server/navigation-relay.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, test } from 'bun:test' +import { createNavigationRelay } from './navigation-relay' +import type { NavigationRequest } from '@/lib/navigation' + +function client() { + const requests: NavigationRequest[] = [] + return { + requests, + send: (data: string) => { + requests.push(JSON.parse(data)) + } + } +} + +function presence(workspaceId: string | null, focused = false) { + return { type: 'navigation:presence', workspaceId, focused } +} + +describe('navigation delivery', () => { + test('targets the last focused browser, retaining focus order after blur, and requires its acknowledgement', async () => { + const relay = createNavigationRelay() + const a = client(), + b = client(), + other = client() + relay.receive(a, presence('ws', true)) + relay.receive(b, presence('ws', true)) + relay.receive(b, presence('ws', false)) + relay.receive(other, presence('elsewhere', true)) + let acknowledged = false + const done = relay.navigate('ws', 'moi:/overview').then(() => { + acknowledged = true + }) + expect(a.requests).toHaveLength(0) + expect(other.requests).toHaveLength(0) + expect(b.requests).toHaveLength(1) + const requestId = b.requests[0].requestId + relay.receive(a, { type: 'navigation:result', requestId, ok: true }) + await Promise.resolve() + expect(acknowledged).toBe(false) + relay.receive(b, { type: 'navigation:result', requestId, ok: true }) + await done + expect(acknowledged).toBe(true) + }) + test('uses a sole browser even if it has never had focus', async () => { + const relay = createNavigationRelay() + const a = client() + relay.receive(a, presence('ws')) + const done = relay.navigate('ws', 'moi:/overview') + relay.receive(a, { type: 'navigation:result', requestId: a.requests[0].requestId, ok: true }) + await done + }) + test('rejects missing and ambiguous clients without broadcasting', async () => { + const relay = createNavigationRelay() + await expect(relay.navigate('ws', 'moi:/overview')).rejects.toThrow('No browser') + const a = client(), + b = client() + relay.receive(a, presence('ws')) + relay.receive(b, presence('ws')) + await expect(relay.navigate('ws', 'moi:/overview')).rejects.toThrow('Several browsers') + expect(a.requests).toHaveLength(0) + expect(b.requests).toHaveLength(0) + }) + test('disconnect and workspace switches cancel pending navigation and remove stale candidates', async () => { + const relay = createNavigationRelay() + const a = client() + relay.receive(a, presence('ws', true)) + const disconnected = relay.navigate('ws', 'moi:/overview') + relay.remove(a) + await expect(disconnected).rejects.toThrow('disconnected') + await expect(relay.navigate('ws', 'moi:/overview')).rejects.toThrow('No browser') + relay.receive(a, presence('ws', true)) + const switched = relay.navigate('ws', 'moi:/overview') + relay.receive(a, presence(null)) + await expect(switched).rejects.toThrow('switched workspaces') + }) + test('propagates browser errors and times out without retrying', async () => { + const relay = createNavigationRelay(10) + const a = client() + relay.receive(a, presence('ws', true)) + const failed = relay.navigate('ws', 'moi:/views/missing') + relay.receive(a, { + type: 'navigation:result', + requestId: a.requests[0].requestId, + error: 'View unavailable' + }) + await expect(failed).rejects.toThrow('View unavailable') + await expect(relay.navigate('ws', 'moi:/overview')).rejects.toThrow('timed out') + expect(a.requests).toHaveLength(2) + relay.receive(a, { type: 'navigation:result', requestId: a.requests[1].requestId, ok: true }) + }) +}) diff --git a/server/navigation-relay.ts b/server/navigation-relay.ts new file mode 100644 index 00000000..7167c4f4 --- /dev/null +++ b/server/navigation-relay.ts @@ -0,0 +1,110 @@ +import type { NavigationRequest } from '@/lib/navigation' +import { isParamsRecord } from '@/lib/workspace-tabs' + +type NavigationClient = { send: (data: string) => unknown } +type Presence = { workspaceId: string | null; focusOrder: number } +type Pending = { + client: NavigationClient + workspaceId: string + resolve: () => void + reject: (error: Error) => void + timer: ReturnType +} + +// One registry on the existing events socket. Server arrival order avoids +// client clock skew; blur deliberately retains the last focus for terminal use. +export function createNavigationRelay(timeoutMs = 5000) { + const clients = new Map() + const pending = new Map() + let focusOrder = 0 + + function finish(id: string, error?: string) { + const request = pending.get(id) + if (!request) return + clearTimeout(request.timer) + pending.delete(id) + if (error) request.reject(new Error(error)) + else request.resolve() + } + + function remove(client: NavigationClient) { + clients.delete(client) + for (const [id, request] of pending) { + if (request.client === client) + finish(id, 'The browser disconnected before acknowledging navigation.') + } + } + + function receive(client: NavigationClient, data: unknown) { + if (!isParamsRecord(data)) return + if ( + data.type === 'navigation:presence' && + (typeof data.workspaceId === 'string' || data.workspaceId === null) && + typeof data.focused === 'boolean' + ) { + const previous = clients.get(client) + const workspaceId = data.workspaceId as string | null + clients.set(client, { + workspaceId, + focusOrder: data.focused + ? ++focusOrder + : previous?.workspaceId === workspaceId + ? previous.focusOrder + : 0 + }) + for (const [id, request] of pending) { + if (request.client === client && request.workspaceId !== workspaceId) + finish(id, 'The browser switched workspaces before acknowledging navigation.') + } + } else if (data.type === 'navigation:result' && typeof data.requestId === 'string') { + const request = pending.get(data.requestId) + if (!request || request.client !== client) return + if (typeof data.error === 'string') finish(data.requestId, data.error) + else if (data.ok === true) finish(data.requestId) + } + } + + function navigate(workspaceId: string, href: string): Promise { + const matching = [...clients].filter(([, presence]) => presence.workspaceId === workspaceId) + matching.sort((a, b) => b[1].focusOrder - a[1].focusOrder) + if (!matching.length) + return Promise.reject( + new Error('No browser is showing this workspace. Open it in moi and try again.') + ) + if (matching.length > 1 && matching[0][1].focusOrder === 0) { + return Promise.reject( + new Error( + 'Several browsers show this workspace. Focus the one to navigate, then try again.' + ) + ) + } + const client = matching[0][0] + const requestId = crypto.randomUUID() + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => + finish( + requestId, + 'Navigation acknowledgement timed out. Check the browser before retrying.' + ), + timeoutMs + ) + pending.set(requestId, { client, workspaceId, resolve, reject, timer }) + const request: NavigationRequest = { + type: 'navigation:request', + requestId, + workspaceId, + href + } + try { + client.send(JSON.stringify(request)) + } catch { + finish(requestId, 'Could not send navigation to the browser.') + } + }) + } + + return { receive, remove, navigate } +} + +export const navigationRelay = createNavigationRelay() diff --git a/server/tabs.test.ts b/server/tabs.test.ts index eebd8223..aa25caa0 100644 --- a/server/tabs.test.ts +++ b/server/tabs.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import type { ViewInfo } from '@/lib/types' -import { assembleTabRows, resolveFocusTab } from './tabs' +import { assembleTabRows, resolveNavigation } from './tabs' const views: ViewInfo[] = [ { id: 'roadmap', config: { title: 'Roadmap' } }, @@ -34,39 +34,39 @@ describe('assembleTabRows', () => { }) }) -describe('resolveFocusTab', () => { - const deps = { - hasView: (id: string) => Promise.resolve(id === 'roadmap'), - viewList: () => Promise.resolve(views) - } - - test('accepts static tab ids', async () => { - expect(await resolveFocusTab('overview', deps)).toEqual({ ok: true, tab: 'overview' }) - expect(await resolveFocusTab('agent', deps)).toEqual({ ok: true, tab: 'agent' }) - expect(await resolveFocusTab('scratchpad', deps)).toEqual({ ok: true, tab: 'scratchpad' }) - }) - - test('accepts a view tab whose view exists', async () => { - expect(await resolveFocusTab('view:roadmap', deps)).toEqual({ ok: true, tab: 'view:roadmap' }) +describe('resolveNavigation', () => { + const deps = { hasView: (id: string) => Promise.resolve(id === 'roadmap') } + test('accepts current public destinations and canonicalizes query params', async () => { + expect(await resolveNavigation('moi:/overview', deps)).toEqual({ + ok: true, + href: 'moi:/overview' + }) + expect(await resolveNavigation('moi:/scratchpad', deps)).toEqual({ + ok: true, + href: 'moi:/scratchpad' + }) + expect(await resolveNavigation('moi:/views/roadmap?z=1&a=2', deps)).toEqual({ + ok: true, + href: 'moi:/views/roadmap?a=2&z=1' + }) }) - - test('rejects an unknown view id, listing the valid ids', async () => { - const result = await resolveFocusTab('view:nope', deps) - expect(result.ok).toBe(false) - if (!result.ok) { - expect(result.error).toContain('view:nope') - expect(result.error).toContain('overview, agent, scratchpad, view:roadmap, view:orders') + test('rejects missing views, old tab IDs, private and future destinations', async () => { + for (const href of [ + 'moi:/views/missing', + 'view:roadmap', + 'moi:/agent', + 'moi:/view-builders/abc', + 'moi:/chats/abc', + 'moi:/files/a.txt', + '', + undefined + ]) { + expect((await resolveNavigation(href, deps)).ok).toBe(false) } }) - - test('rejects the old Widgets tab id', async () => { - expect((await resolveFocusTab('widgets', deps)).ok).toBe(false) - }) - - test('rejects view-builder tabs and garbage', async () => { - expect((await resolveFocusTab('view-builder:abc', deps)).ok).toBe(false) - expect((await resolveFocusTab('Roadmap', deps)).ok).toBe(false) - expect((await resolveFocusTab('', deps)).ok).toBe(false) - expect((await resolveFocusTab(undefined, deps)).ok).toBe(false) + test('discovery includes portable links but no singleton chat contract', () => { + const rows = assembleTabRows(views, 'overview') + expect(rows.find(row => row.id === 'view:orders')?.href).toBe('moi:/views/orders') + expect(rows.find(row => row.id === 'agent')?.href).toBeUndefined() }) }) diff --git a/server/tabs.ts b/server/tabs.ts index af203d7f..834e38c6 100644 --- a/server/tabs.ts +++ b/server/tabs.ts @@ -1,7 +1,6 @@ -// `moi tabs` / `moi tabs focus` server logic: assemble the tab listing and -// validate a focus target. Pure given its inputs — the control handler wires -// in the workspace lookups (see control.ts), tests pass fakes. +// Tab discovery and server-side validation for portable navigation addresses. import type { ViewInfo, WorkspaceTabId } from '@/lib/types' +import { moiHref, parseMoiHref } from '@/lib/navigation' import { viewIdFromTab, viewTabId } from '@/lib/workspace-tabs' // One row of `moi tabs`. `isDefault` marks the workspace's saved default tab @@ -10,6 +9,7 @@ export type TabRow = { id: WorkspaceTabId title: string isDefault: boolean + href?: string } // The always-present tabs, titled like the tab bar renders them. @@ -26,34 +26,34 @@ export function assembleTabRows(views: ViewInfo[], defaultTab: WorkspaceTabId): return [ ...STATIC_TABS, ...views.map(view => ({ id: viewTabId(view.id), title: view.config.title || view.id })) - ].map(row => ({ ...row, isDefault: row.id === defaultTab })) + ].map(row => ({ + ...row, + isDefault: row.id === defaultTab, + ...(row.id === 'agent' ? {} : { href: moiHref(row.id) }) + })) } -type FocusTabDeps = { +type NavigationDeps = { // Whether a view id exists in the workspace (source or built) — hasViewId. hasView: (viewId: string) => Promise - // The built views, for the error message's valid-id list — getViewList. - viewList: () => Promise } -export type FocusTabResult = { ok: true; tab: WorkspaceTabId } | { ok: false; error: string } - -// Validate a `moi tabs focus` target: static ids pass as-is, `view:` must -// name a real view. Anything else — including view-builder tabs — fails with -// the list of valid ids. Addressing is by tab id, never by title. -export async function resolveFocusTab(raw: unknown, deps: FocusTabDeps): Promise { - const tab = typeof raw === 'string' ? raw.trim() : '' - if (STATIC_TABS.some(row => row.id === tab)) return { ok: true, tab: tab as WorkspaceTabId } - - const viewId = tab.startsWith('view:') ? viewIdFromTab(tab as WorkspaceTabId) : null - if (viewId && (await deps.hasView(viewId))) return { ok: true, tab: tab as WorkspaceTabId } - - const validIds = [ - ...STATIC_TABS.map(row => row.id), - ...(await deps.viewList()).map(view => viewTabId(view.id)) - ] - return { - ok: false, - error: `Unknown tab "${tab || String(raw ?? '')}". Valid tabs: ${validIds.join(', ')}` +export type NavigationResult = { ok: true; href: string } | { ok: false; error: string } + +export async function resolveNavigation( + raw: unknown, + deps: NavigationDeps +): Promise { + try { + const address = parseMoiHref(raw) + const viewId = viewIdFromTab(address.tab) + if (viewId && !(await deps.hasView(viewId))) + return { ok: false, error: `View "${viewId}" does not exist in this workspace.` } + return { ok: true, href: moiHref(address.tab, address.search) } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Invalid workspace address' + } } } diff --git a/server/test/__fixtures__/with-focustab.tsx b/server/test/__fixtures__/with-focustab.tsx deleted file mode 100644 index 444c4949..00000000 --- a/server/test/__fixtures__/with-focustab.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { focusTab } from 'moi' -export const config = { title: 'Focus' } -export default function WithFocus() { - return -} diff --git a/server/test/__fixtures__/with-navigation.tsx b/server/test/__fixtures__/with-navigation.tsx new file mode 100644 index 00000000..9e0597db --- /dev/null +++ b/server/test/__fixtures__/with-navigation.tsx @@ -0,0 +1,5 @@ +import { navigate } from 'moi' +export const config = { title: 'Focus' } +export default function WithFocus() { + return +} diff --git a/server/test/build-applet.test.ts b/server/test/build-applet.test.ts index 023fa64e..3f411f61 100644 --- a/server/test/build-applet.test.ts +++ b/server/test/build-applet.test.ts @@ -512,9 +512,9 @@ describe('moi fileUrl module', () => { expect(result.js).toContain('"/fs/"') }) - test('bundles focusTab forwarding to the per-bundle bridge, not a global', async () => { - const result = await buildApplet(join(FIXTURES, 'with-focustab.tsx'), undefined, 'view') - expect(result.js).toContain('function focusTab') + test('bundles navigate forwarding to the per-bundle bridge, not a global', async () => { + const result = await buildApplet(join(FIXTURES, 'with-navigation.tsx'), undefined, 'view') + expect(result.js).toContain('function navigate') // Optional-chained so calls no-op before the host attaches (and outside // the moi host). The compiled form may or may not keep the `?.` sugar. expect(result.js).toMatch(/bridge\s*(\?\.|&&|==)/) @@ -540,7 +540,7 @@ describe('moi fileUrl module', () => { test('every bundle entry exports the bridge wiring, even without a moi import', async () => { // `hello` never imports moi at all — the entry still re-exports the host // wiring so attach works uniformly across bundles. - for (const fixture of ['with-focustab.tsx', 'hello.tsx']) { + for (const fixture of ['with-navigation.tsx', 'hello.tsx']) { const result = await buildApplet(join(FIXTURES, fixture), undefined, 'view') expect(result.js).toContain('__attachBridge') expect(result.js).toContain('__getBridge') diff --git a/server/test/cli-help.test.ts b/server/test/cli-help.test.ts index 03da39ec..3ace8b8f 100644 --- a/server/test/cli-help.test.ts +++ b/server/test/cli-help.test.ts @@ -74,13 +74,26 @@ describe('moi --help (e2e)', () => { expect(out).not.toContain('System commands:') }, 30_000) - test('tabs owns the focus subcommand without a singular alias', async () => { + test('navigation has its own command and tabs is discovery only', async () => { const rootHelp = await runHelp({}) const tabsHelp = await runHelp({}, 'tabs') - expect(rootHelp).toContain('moi tabs focus ') + expect(rootHelp).toContain('navigate') + expect(rootHelp).not.toContain('tabs focus') expect(rootHelp).not.toMatch(/^\s+tab\s/m) - expect(tabsHelp).toContain('focus Focus a workspace tab in every open client') - expect(tabsHelp).toContain('Use moi tabs --help') + expect(tabsHelp).not.toContain('focus') + const navigationHelp = await runHelp({}, 'navigate') + expect(navigationHelp).not.toContain('--replace') + expect(navigationHelp).toContain('moi:/views/events') }, 30_000) }) + +test('removed tab focus command fails before contacting a server', async () => { + const proc = Bun.spawn(['bun', CLI, 'tabs', 'focus', 'view:events'], { + stdout: 'pipe', + stderr: 'pipe' + }) + const [error, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]) + expect(code).toBe(1) + expect(error).toContain('Use moi navigate
') +}) diff --git a/server/test/navigation-cli.test.ts b/server/test/navigation-cli.test.ts new file mode 100644 index 00000000..cfa6974a --- /dev/null +++ b/server/test/navigation-cli.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from 'bun:test' +import { join } from 'node:path' + +const CLI = join(import.meta.dir, '..', 'cli.ts') + +test('navigation CLI sends a portable address and waits for a matching response', async () => { + let request: unknown + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, server) { + if (server.upgrade(req)) return + return new Response('', { status: 400 }) + }, + websocket: { + message(ws, message) { + request = JSON.parse(String(message)) + ws.send(JSON.stringify({ ok: true, href: 'moi:/overview' })) + } + } + }) + try { + const proc = Bun.spawn(['bun', CLI, 'navigate', 'moi:/overview'], { + env: { ...process.env, MOI_CONTROL_PORT: String(server.port) }, + stdout: 'pipe', + stderr: 'pipe' + }) + const [output, code] = await Promise.all([new Response(proc.stdout).text(), proc.exited]) + expect(request).toMatchObject({ type: 'navigate', href: 'moi:/overview' }) + expect(request).not.toHaveProperty('replace') + expect(code).toBe(0) + expect(output).toContain('Navigated to moi:/overview') + } finally { + server.stop(true) + } +}) + +test('navigation CLI exits nonzero when the control server disconnects without acknowledgement', async () => { + const server = Bun.serve({ + port: 0, + hostname: '127.0.0.1', + fetch(req, server) { + if (server.upgrade(req)) return + return new Response('', { status: 400 }) + }, + websocket: { + message(ws) { + ws.close() + } + } + }) + try { + const proc = Bun.spawn(['bun', CLI, 'navigate', 'moi:/overview'], { + env: { ...process.env, MOI_CONTROL_PORT: String(server.port) }, + stdout: 'pipe', + stderr: 'pipe' + }) + const [error, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]) + expect(code).toBe(1) + expect(error).toContain('disconnected before acknowledging') + } finally { + server.stop(true) + } +}) diff --git a/server/test/skills-template.test.ts b/server/test/skills-template.test.ts index 4aa1737c..8d5f1302 100644 --- a/server/test/skills-template.test.ts +++ b/server/test/skills-template.test.ts @@ -29,13 +29,16 @@ describe('installBundledSkills', () => { const intents = await Bun.file(join(dir, 'moi-workspace', 'references', 'INTENTS.md')).text() expect(intents).toContain('function addChatAttachment(input:') expect(intents).toContain('function sendChatMessage(input:') - expect(intents).toContain('function focusTab(tab:') + expect(intents).toContain('function navigate(href:') + expect(intents).toContain('function resolveHref(href:') + expect(intents).not.toContain('focusTab') expect(skillMd).toContain('moi ui-components add') expect(skillMd).toContain('### Appearance and settings') expect(skillMd).toContain('moi theme --font= --color=') expect(skillMd).toContain('### Environment and secrets') expect(skillMd).toContain('moi env exec -- bun script.ts') - expect(skillMd).toContain('moi tabs focus') + expect(skillMd).toContain('moi navigate') + expect(skillMd).not.toContain('moi tabs focus') expect(skillMd).not.toContain('moi tab focus') expect(skillMd).toContain('moi --help') expect(skillMd).not.toContain('moi help') diff --git a/server/web.ts b/server/web.ts index 44fc96ab..c25b464f 100644 --- a/server/web.ts +++ b/server/web.ts @@ -11,6 +11,7 @@ import { EVENTS_TOPIC, publishEvent, setEventServer } from './events' import { killBuildWorkers } from './applets/build-worker' import { killAllWorkers } from './functions' import { startScratchpadSweeper } from './scratchpad' +import { navigationRelay } from './navigation-relay' import { resolveScratchOp } from './scratchpad-relay' import { allHarnesses, harnessFor } from './harness/registry' import { getWorkspace } from './registry' @@ -146,7 +147,12 @@ export const app = Bun.serve({ } }, async message(ws, message) { - if (ws.data.channel !== 'chat') return + if (ws.data.channel === 'events') { + try { + navigationRelay.receive(ws, JSON.parse(String(message))) + } catch {} + return + } try { const data = JSON.parse(String(message)) if (!isClientMessage(data)) return @@ -209,7 +215,10 @@ export const app = Bun.serve({ }, close(ws) { if (ws.data.channel === 'chat') removeClient(ws) - else ws.unsubscribe(EVENTS_TOPIC) + else { + navigationRelay.remove(ws) + ws.unsubscribe(EVENTS_TOPIC) + } } } }) diff --git a/workspace/.claude/skills/moi-workspace/SKILL.md b/workspace/.claude/skills/moi-workspace/SKILL.md index eab61cc5..8bd65dca 100644 --- a/workspace/.claude/skills/moi-workspace/SKILL.md +++ b/workspace/.claude/skills/moi-workspace/SKILL.md @@ -110,15 +110,40 @@ Use the task-specific sections below for workflow guidance. The CLI will grow ov - **Develop applets:** `moi check`, `moi bundle`, and `moi refresh`. - **Call actions:** `moi call-server-fn`. - **Debug applets:** `moi debug logs` (see Debugging). -- **Navigate the workspace:** `moi tabs` and `moi tabs focus` (see Navigating the workspace). +- **Navigate the workspace:** `moi tabs` and `moi navigate
` (see Workspace navigation). - **Customize the workspace:** `moi theme` and `moi config` (see Appearance and settings). - **Use workspace env:** `moi env` and `moi env exec` (see Environment and secrets). - **Maintain workspace guidance:** `moi skill` (see Keeping this skill current). -### Navigating the workspace +### Workspace navigation + +Use `moi:/` addresses in chat, CLI commands, and applets. They refer to the current workspace, +independently of its domain and deployment path. + +Supported destinations: `moi:/overview`, `moi:/scratchpad`, and `moi:/views/`. +Run `moi tabs` to discover views and their addresses. Widgets are not navigation destinations. + +Put view params in the query string. Values are strings; read the target view's `Params` type +for supported keys and use `URLSearchParams` to encode dynamic values. + +Link in chat: + +```md +[Open order](moi:/views/orders?order=o-1024) +``` + +Navigate from the CLI: + +```sh +moi navigate 'moi:/views/orders?order=o-1024' +``` + +The CLI moves the most recently focused browser showing this workspace and waits for its URL +acknowledgement. A timeout may mean navigation happened; inspect the browser before retrying. + +For applet `navigate(href)` and `resolveHref(href)` usage, see +[Applet intents](references/INTENTS.md#navigation-navigatehref-and-resolvehrefhref). -Run `moi tabs` to discover available tabs and `moi tabs focus ` to show one to the user. -For navigation triggered inside an applet, use `focusTab`; see [Applet intents](references/INTENTS.md). After building or editing an applet, follow [Verification and handoff](#verification-and-handoff). ### Appearance and settings @@ -258,7 +283,13 @@ Changing `colSpan`/`rowSpan` needs `moi bundle --force --only widgets/`. See ### Views Full-screen apps, one per nav tab — the user switches tabs. A view has no router of its own, but it -can be addressed: see [Applet intents](references/INTENTS.md) for `focusTab` and the `params` prop. +can be addressed: see [Applet intents](references/INTENTS.md) for `navigate`, `resolveHref`, and the +`params` prop. + +Use URL query params for state that should survive reloads or be shareable, such as the selected +event. Render it directly from the `params` prop and call `navigate()` when it changes, including +when opening or closing details inside the view. Keep temporary state, such as drafts and hover, +in React. See the [view params example](references/INTENTS.md#view-params-and-history). ```ts export const config = { @@ -389,10 +420,10 @@ needs another focused check. Do not search for repo tests by default. Run an exi `moi call-server-fn` only when the change touches the behavior it covers. If native-app inspection is unavailable, keep verification in the browser instead of retrying the unsupported tool. -After the final successful checks, always make tab focus the final workspace action: +After the final successful checks, always make navigation to the result the final workspace action: -- After building or editing a widget, run `moi tabs focus widgets`. -- After building or editing a view, run `moi tabs focus view:`, using its file name or claimed +- After building or editing a widget, run `moi navigate 'moi:/overview'`. +- After building or editing a view, run `moi navigate 'moi:/views/'`, using its file name or claimed builder id. The focused applet is the handoff. Keep the final reply brief and user-facing. Do not include file @@ -459,4 +490,4 @@ This skill is installed with moi (via the CLI or the UI) and can fall behind whe - **Then** — if you updated, mention it. - + diff --git a/workspace/.claude/skills/moi-workspace/references/INTENTS.md b/workspace/.claude/skills/moi-workspace/references/INTENTS.md index f72e50de..2a069d2a 100644 --- a/workspace/.claude/skills/moi-workspace/references/INTENTS.md +++ b/workspace/.claude/skills/moi-workspace/references/INTENTS.md @@ -3,57 +3,59 @@ Intents let an applet act beyond its own UI: open another view, add context to chat, or ask the agent to do something. Import these functions from `moi` and call them from user event handlers, such as a button click; moi handles the action in the workspace. +Action functions return `void`; `resolveHref` returns a browser href. moi identifies the source +applet automatically for chat actions. For rejected chat intents, inspect `moi debug logs`. -## `focusTab(tab, params?)` +## Navigation: `navigate(href)` and `resolveHref(href)` -Use this to take the user to another workspace tab or view, optionally passing params. -No chat message is sent. Widgets cannot be navigation targets; their `params` is always `{}`. - -### API +Use the shared [workspace navigation convention](../SKILL.md#workspace-navigation) for addresses +and query params. Applets navigate through these functions: ```ts -function focusTab(tab: string, params?: Record): void +function navigate(href: string): void +function resolveHref(href: string): string ``` -- `tab`: required workspace tab id. Use `overview`, `scratchpad`, or `view:`. - Run `moi tabs` to discover available tabs. -- `params`: optional JSON object describing the target view's addressable state. It arrives - through that view's `params` prop. +```tsx +import { navigate, resolveHref } from 'moi' -### Example +const query = new URLSearchParams({ order: 'o-1024' }) +const href = `moi:/views/orders?${query}` -```tsx -import { focusTab } from 'moi' +// Prefer anchors for links: copy, middle-click, and new browser tabs work. +Open order - +// Use the same address from an event handler. + ``` -### View params contract +### View params and history -A view with addressable state declares a local `Params` type in its own file. Every field is -optional and carries a comment, because the view must render sensibly with `{}` — a fresh mount, a -plain tab-bar click, or a new browser tab all deliver nothing. Keep `params` small and JSON-serializable, since browser history copies them without preserving object identity. +The host passes query values to the view's `params` prop. Missing keys are absent. Decode numbers +and booleans explicitly, and render sensibly with empty params. ```tsx -// .moi/views/orders.tsx -// The view's addressable state — what `focusTab('view:orders', …)` can set. type Params = { - // Order id to open in the detail pane; omit to show the list. + // Order id to open; omit to show the list. order?: string } +type OrdersProps = { params?: Params } -export default function Orders({ params = {} }: { params?: Params }) { - // Values arrive from navigation state, so narrow before trusting them. - const openOrder = typeof params.order === 'string' ? params.order : null - … +export default function Orders({ params = {} }: OrdersProps) { + const selectedOrder = params.order ?? null + return } ``` -**Applets never import from each other, not even types.** Before wiring a `focusTab` call, read the -target view's source, mirror the shape you find there, and note where you read it. That file is the -contract; the type is documentation, not a shared module. +See [Views](../SKILL.md#views) for which state belongs in the URL. + +Normal navigation adds browser history. Back, Forward, reload, and copied links restore the address. +Tab clicks restore each tab's last address in browser memory. An explicit root link clears params. +Inactive views keep their own params. Widgets receive no params. + +**Applets never import from each other, not even types.** + +`resolveHref` preserves HTTP(S) URLs. Native external links keep their normal browser behavior. ## `addChatAttachment(input)` From fd47ca8ef332f497d942439b6c2f04b5fc24cdbe Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 23:18:11 +0200 Subject: [PATCH 2/7] Fix workspace navigation URL decoding and validation --- client/app/shell/SidebarLayout.tsx | 2 +- client/features/applets/applet-runtime.ts | 2 +- .../workspace/useWorkspaceNavigation.test.tsx | 68 +++++++++++++++++++ .../workspace/useWorkspaceNavigation.ts | 15 ++-- docs/navigation.md | 7 +- lib/navigation.test.ts | 9 +-- lib/navigation.ts | 10 ++- server/control.ts | 16 ++--- server/tabs.test.ts | 41 ++--------- server/tabs.ts | 31 +-------- 10 files changed, 107 insertions(+), 94 deletions(-) create mode 100644 client/features/workspace/useWorkspaceNavigation.test.tsx diff --git a/client/app/shell/SidebarLayout.tsx b/client/app/shell/SidebarLayout.tsx index 74ad4793..737684bc 100644 --- a/client/app/shell/SidebarLayout.tsx +++ b/client/app/shell/SidebarLayout.tsx @@ -199,7 +199,7 @@ function WorkspaceButton({ workspace, dragOverlay = false, dragState }: Workspac const [location] = useLocation() const href = `/workspace/${workspace.id}` const label = workspaceDisplayName(workspace) - // A workspace URL carries a tab suffix (`/workspace/:id/view:orders`), so the + // A workspace URL carries a destination (`/workspace/:id/views/orders`), so the // rail matches the workspace segment, not the whole path. The trailing slash // keeps `/workspace/ws1` from lighting up a sibling `/workspace/ws1-other`. const active = location === href || location.startsWith(`${href}/`) diff --git a/client/features/applets/applet-runtime.ts b/client/features/applets/applet-runtime.ts index 963bbc10..e3df0ab7 100644 --- a/client/features/applets/applet-runtime.ts +++ b/client/features/applets/applet-runtime.ts @@ -120,7 +120,7 @@ function createRuntime(workspaceId: string) { return emitter.on(event, cb) }, // One connection per loaded module instance. The bridge validates every - // call — a malformed tab id or params shape from applet code drops the + // call — a malformed address or chat input from applet code drops the // call instead of being emitted — and `dispose` flips the connection dead // so a disposed module can never act again. Emitting with no subscribers // (workspace screen unmounted) is a no-op by nanoevents semantics. diff --git a/client/features/workspace/useWorkspaceNavigation.test.tsx b/client/features/workspace/useWorkspaceNavigation.test.tsx new file mode 100644 index 00000000..e4bacd3b --- /dev/null +++ b/client/features/workspace/useWorkspaceNavigation.test.tsx @@ -0,0 +1,68 @@ +import { expect, test } from 'bun:test' +import { renderToStaticMarkup } from 'react-dom/server' +import { Route, Router } from 'wouter' + +import type { ViewInfo } from '@/lib/types' +import { createDefaultWorkspaceLayout } from '@/lib/workspace-layout' +import { WorkspaceLayoutContext } from './WorkspaceLayoutContext' +import { useWorkspaceNavigation } from './useWorkspaceNavigation' + +function readNavigation(path: string, search: string, views: ViewInfo[], base = '') { + function Probe() { + const { activeTab, appletParams, isUnavailable } = useWorkspaceNavigation({ + views, + builders: [], + split: false + }) + return ( + + ) + } + const html = renderToStaticMarkup( + + + {}, + name: null, + cwd: null, + provider: null, + workspaceId: 'abc', + isLoading: false + }} + > + + + + + ) + if (!html) throw new Error('The workspace route did not match') + return JSON.parse(html.slice(html.indexOf('>') + 1, html.lastIndexOf('<'))) as Pick< + ReturnType, + 'activeTab' | 'appletParams' | 'isUnavailable' + > +} + +test('view params decode exactly once through the real router', () => { + const params = { literal: '%20 %26 %2F', json: '{"value":"100%"}', plus: '+' } + const result = readNavigation('views/events', new URLSearchParams(params).toString(), [ + { id: 'events', config: {} } + ]) + expect(result.appletParams).toEqual(params) +}) + +test('encoded IDs resolve under a deployment base without decoding nested escapes', () => { + const views = [{ id: 'events', config: {} }] + expect(readNavigation('views/%65vents', '', views, '/prefix').activeTab).toBe('view:events') + expect(readNavigation('views/%2565vents', '', views, '/prefix').isUnavailable).toBe(true) +}) + +test('missing views keep their destination without becoming the default tab', () => { + const result = readNavigation('views/missing', '?eventId=123', []) + expect(result.activeTab).toBe('view:missing') + expect(result.isUnavailable).toBe(true) + expect(result.appletParams).toEqual({ eventId: '123' }) +}) diff --git a/client/features/workspace/useWorkspaceNavigation.ts b/client/features/workspace/useWorkspaceNavigation.ts index 88f77964..439fa11b 100644 --- a/client/features/workspace/useWorkspaceNavigation.ts +++ b/client/features/workspace/useWorkspaceNavigation.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useMemo } from 'react' import type { MouseEvent } from 'react' -import { useLocation, useParams, useRouter, useSearch } from 'wouter' +import { useLocation, useParams, useRouter } from 'wouter' +import { usePathname, useSearch } from 'wouter/use-browser-location' import { toast } from '@/client/components/ui/toast' import { reportAppletError } from '@/client/features/applets/applet-log' @@ -31,9 +32,13 @@ type UseWorkspaceNavigationOptions = { views: ViewInfo[]; builders: ViewBuilder[ export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceNavigationOptions) { const { layout, setLayout, workspaceId } = useWorkspaceLayoutCtx() const [, navigate] = useLocation() - const { base } = useRouter() - const path = useParams()['*'] ?? '' - const search = canonicalSearch(useSearch()) + const router = useRouter() + const { base } = router + // wouter's public route/search hooks decode URI escapes already. Read raw + // browser values so tabFromPath and URLSearchParams each decode only once. + const path = usePathname(router).slice(workspacePath(workspaceId, base).length + 1) + const search = canonicalSearch(useSearch(router)) + const legacyPath = useParams()['*'] const appletParams = useMemo(() => readViewParams(search), [search]) const tabsState = normalizeTabsState(layout.tabs) const tabsStateRef = useLatestRef(tabsState) @@ -43,7 +48,7 @@ export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceN return entries }, [workspaceId]) const requestedTab = tabFromPath(path) - const legacyTab = requestedTab ? null : parseWorkspaceTab(path) + const legacyTab = requestedTab ? null : parseWorkspaceTab(legacyPath) const activeTab = resolveActiveTab(requestedTab ?? legacyTab, tabsState, views, builders, split) const isUnavailable = Boolean(path) && !legacyTab && (!requestedTab || !tabAvailable(requestedTab, views, builders)) diff --git a/docs/navigation.md b/docs/navigation.md index 835b364b..33438eaa 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -34,14 +34,15 @@ and shows recovery to Overview. `fileUrl()` remains the resource URL API. ## CLI transport -`moi tabs` lists addresses. `moi navigate
` validates the destination, then +`moi tabs` lists addresses. `moi navigate
` validates the address format, then uses the existing events WebSocket to address one browser. Each browser reports its displayed workspace and focus. Server arrival order chooses the most recently focused connected browser showing that workspace, even after focus moves to a terminal. A sole client needs no focus record; multiple clients without a focus record require the user to focus one first. -The browser acknowledges after applying the URL, without waiting for view data. Only the addressed -socket may settle its request. Disconnects and workspace switches fail pending requests. The +The browser checks that the destination exists and acknowledges after applying the URL, without +waiting for view data. Only the addressed socket may settle its request. +Disconnects and workspace switches fail pending requests. The five-second timeout does not retry: navigation may already have happened. ## Migration diff --git a/lib/navigation.test.ts b/lib/navigation.test.ts index 8563b0f6..71e30035 100644 --- a/lib/navigation.test.ts +++ b/lib/navigation.test.ts @@ -21,10 +21,8 @@ describe('workspace addresses', () => { ) expect(resolveWorkspaceHref('abc', href)).toBe('/workspace/abc/views/events?eventId=123') }) - test('view filenames with dots, Unicode, and encoded spaces remain addressable', () => { - expect(parseMoiHref('moi:/views/my.events').tab).toBe('view:my.events') - expect(parseMoiHref('moi:/views/Gr%C3%BC%C3%9Fe%20events').tab).toBe('view:Grüße events') - expect(tabPath('view:Grüße events')).toBe('views/Gr%C3%BC%C3%9Fe%20events') + test('accepts encoded IDs supported by the applet server', () => { + expect(parseMoiHref('moi:/views/%65vents_2026-09').tab).toBe('view:events_2026-09') }) test('query values remain strings and follow URLSearchParams.get semantics', () => { @@ -50,6 +48,9 @@ describe('workspace addresses', () => { 'moi:/views/a/b', 'moi:/views/%2f', 'moi:/views/%', + 'moi:/views/100%25', + 'moi:/views/my.events', + 'moi:/views/Gr%C3%BC%C3%9Fe%20events', 'moi:/views/../overview', 'moi:/views/a#part', 'moi:/agent', diff --git a/lib/navigation.ts b/lib/navigation.ts index ecb55b12..56a6f505 100644 --- a/lib/navigation.ts +++ b/lib/navigation.ts @@ -41,13 +41,11 @@ export function tabPath(tab: WorkspaceTabId): string { export function tabFromPath(path: string): WorkspaceTabId | null { if (path === 'overview' || path === 'scratchpad' || path === 'agent') return path - const match = /^(views|view-builders)\/([^/]+)$/.exec(path) - if (!match) return null try { - const id = decodeURIComponent(match[2]) - // eslint-disable-next-line no-control-regex -- URL path IDs must reject control characters. - if (id === '.' || id === '..' || /[/\\\u0000-\u001f\u007f]/.test(id)) return null - return match[1] === 'views' ? `view:${id}` : `view-builder:${id}` + // Match the applet IDs accepted by the server's module routes. + const match = /^(views|view-builders)\/([a-zA-Z0-9_-]+)$/.exec(decodeURIComponent(path)) + if (!match) return null + return match[1] === 'views' ? `view:${match[2]}` : `view-builder:${match[2]}` } catch { return null } diff --git a/server/control.ts b/server/control.ts index 7845dfd9..c70dc82e 100644 --- a/server/control.ts +++ b/server/control.ts @@ -4,6 +4,7 @@ import { resolve } from 'path' import { parseAppletSelector } from '@/lib/applet-selector' import { resolveWorkspaceTheme } from '@/lib/themes' import type { WorkspaceEntry } from '@/lib/types' +import { moiHref, parseMoiHref } from '@/lib/navigation' import { navigationRelay } from './navigation-relay' import { clearAppletLog, getAppletLog, getAppletLogCount } from './applet-log' @@ -19,7 +20,7 @@ import { executeScratchOp } from './scratchpad-executor' import { readScratchpadImage, readScratchpadShapes } from './scratchpad' import { relayScratchOp } from './scratchpad-relay' import { broadcastAll } from './state' -import { assembleTabRows, resolveNavigation } from './tabs' +import { assembleTabRows } from './tabs' import { applyThemeUpdate } from './theme' import { handleBundle } from './widgets' import { getViewList, handleBundleViews, hasViewId } from './views' @@ -315,16 +316,11 @@ export const control = Bun.serve({ if (data.type === 'navigate') { const match = await resolveWorkspace(ws, data.path) if (!match) return - const resolved = await resolveNavigation(data.href, { - hasView: viewId => hasViewId(match.path, viewId) - }) - if (!resolved.ok) { - ws.send(JSON.stringify({ error: resolved.error })) - return - } try { - await navigationRelay.navigate(match.id, resolved.href) - ws.send(JSON.stringify({ ok: true, href: resolved.href })) + const address = parseMoiHref(data.href) + const href = moiHref(address.tab, address.search) + await navigationRelay.navigate(match.id, href) + ws.send(JSON.stringify({ ok: true, href })) } catch (error) { ws.send( JSON.stringify({ diff --git a/server/tabs.test.ts b/server/tabs.test.ts index aa25caa0..e81459be 100644 --- a/server/tabs.test.ts +++ b/server/tabs.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import type { ViewInfo } from '@/lib/types' -import { assembleTabRows, resolveNavigation } from './tabs' +import { assembleTabRows } from './tabs' const views: ViewInfo[] = [ { id: 'roadmap', config: { title: 'Roadmap' } }, @@ -34,39 +34,8 @@ describe('assembleTabRows', () => { }) }) -describe('resolveNavigation', () => { - const deps = { hasView: (id: string) => Promise.resolve(id === 'roadmap') } - test('accepts current public destinations and canonicalizes query params', async () => { - expect(await resolveNavigation('moi:/overview', deps)).toEqual({ - ok: true, - href: 'moi:/overview' - }) - expect(await resolveNavigation('moi:/scratchpad', deps)).toEqual({ - ok: true, - href: 'moi:/scratchpad' - }) - expect(await resolveNavigation('moi:/views/roadmap?z=1&a=2', deps)).toEqual({ - ok: true, - href: 'moi:/views/roadmap?a=2&z=1' - }) - }) - test('rejects missing views, old tab IDs, private and future destinations', async () => { - for (const href of [ - 'moi:/views/missing', - 'view:roadmap', - 'moi:/agent', - 'moi:/view-builders/abc', - 'moi:/chats/abc', - 'moi:/files/a.txt', - '', - undefined - ]) { - expect((await resolveNavigation(href, deps)).ok).toBe(false) - } - }) - test('discovery includes portable links but no singleton chat contract', () => { - const rows = assembleTabRows(views, 'overview') - expect(rows.find(row => row.id === 'view:orders')?.href).toBe('moi:/views/orders') - expect(rows.find(row => row.id === 'agent')?.href).toBeUndefined() - }) +test('discovery includes portable links but no singleton chat contract', () => { + const rows = assembleTabRows(views, 'overview') + expect(rows.find(row => row.id === 'view:orders')?.href).toBe('moi:/views/orders') + expect(rows.find(row => row.id === 'agent')?.href).toBeUndefined() }) diff --git a/server/tabs.ts b/server/tabs.ts index 834e38c6..a8a1c62f 100644 --- a/server/tabs.ts +++ b/server/tabs.ts @@ -1,7 +1,7 @@ -// Tab discovery and server-side validation for portable navigation addresses. +// Tab discovery with portable navigation addresses. import type { ViewInfo, WorkspaceTabId } from '@/lib/types' -import { moiHref, parseMoiHref } from '@/lib/navigation' -import { viewIdFromTab, viewTabId } from '@/lib/workspace-tabs' +import { moiHref } from '@/lib/navigation' +import { viewTabId } from '@/lib/workspace-tabs' // One row of `moi tabs`. `isDefault` marks the workspace's saved default tab // (`layout.tabs.active`) — where a bare `/workspace/:id` lands. @@ -32,28 +32,3 @@ export function assembleTabRows(views: ViewInfo[], defaultTab: WorkspaceTabId): ...(row.id === 'agent' ? {} : { href: moiHref(row.id) }) })) } - -type NavigationDeps = { - // Whether a view id exists in the workspace (source or built) — hasViewId. - hasView: (viewId: string) => Promise -} - -export type NavigationResult = { ok: true; href: string } | { ok: false; error: string } - -export async function resolveNavigation( - raw: unknown, - deps: NavigationDeps -): Promise { - try { - const address = parseMoiHref(raw) - const viewId = viewIdFromTab(address.tab) - if (viewId && !(await deps.hasView(viewId))) - return { ok: false, error: `View "${viewId}" does not exist in this workspace.` } - return { ok: true, href: moiHref(address.tab, address.search) } - } catch (error) { - return { - ok: false, - error: error instanceof Error ? error.message : 'Invalid workspace address' - } - } -} From 2c5fd18b26a1a4031bc559f32135282d3707fcdb Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Wed, 16 Sep 2026 23:41:10 +0200 Subject: [PATCH 3/7] Use workspace-relative paths for view tab IDs --- client/features/chat/chat-send.test.ts | 8 +-- .../attachments/draft-attachments.test.ts | 4 +- .../attachments/text-attachments.test.ts | 6 +- client/features/home/HomePage.tsx | 2 +- client/features/workspace/moi-context.test.ts | 18 +++--- client/features/workspace/moi-context.ts | 10 ++-- .../features/workspace/tab-resolution.test.ts | 34 +++++------ .../workspace/useWorkspaceNavigation.test.tsx | 13 ++++- .../workspace/useWorkspaceNavigation.ts | 7 +-- docs/navigation.md | 4 +- lib/moi-context.ts | 12 ++-- lib/navigation.test.ts | 26 +++++++-- lib/navigation.ts | 30 +++++----- lib/types.ts | 4 +- lib/workspace-tabs.test.ts | 56 +++++++------------ lib/workspace-tabs.ts | 26 +++------ server/api-views.test.ts | 2 +- server/api.ts | 2 +- server/harness/codex/session.test.ts | 2 +- server/tabs.test.ts | 16 +++--- server/test/layout.test.ts | 32 ++++++++--- server/test/moi-context.test.ts | 20 +++---- server/test/text-attachment-replay.test.ts | 2 +- server/test/view-builder-directives.test.ts | 2 +- 24 files changed, 177 insertions(+), 161 deletions(-) diff --git a/client/features/chat/chat-send.test.ts b/client/features/chat/chat-send.test.ts index 9713cca5..001710df 100644 --- a/client/features/chat/chat-send.test.ts +++ b/client/features/chat/chat-send.test.ts @@ -315,7 +315,7 @@ describe('composer attachments', () => { localId: 'annotation-2', label: 'Annotation.png', mediaType: 'image/png', - source: 'view:roadmap', + source: 'views/roadmap', status: 'ready', upload: { id: 'up-annotation-2', kind: 'image' } as UploadInfo } @@ -327,7 +327,7 @@ describe('composer attachments', () => { { type: 'upload', uploadId: 'up-annotation-2', - source: 'view:roadmap', + source: 'views/roadmap', purpose: 'annotation' } ]) @@ -340,7 +340,7 @@ describe('composer attachments', () => { localId: 'sketch-1', label: 'Sketch.png', mediaType: 'image/png', - source: 'view-builder:draft-1', + source: 'view-builders/draft-1', status: 'ready', upload: { id: 'up-sketch', kind: 'image' } as UploadInfo } @@ -349,7 +349,7 @@ describe('composer attachments', () => { { type: 'upload', uploadId: 'up-sketch', - source: 'view-builder:draft-1', + source: 'view-builders/draft-1', purpose: 'sketch' } ]) diff --git a/client/features/chat/composer/attachments/draft-attachments.test.ts b/client/features/chat/composer/attachments/draft-attachments.test.ts index f2f13576..964eaf68 100644 --- a/client/features/chat/composer/attachments/draft-attachments.test.ts +++ b/client/features/chat/composer/attachments/draft-attachments.test.ts @@ -161,7 +161,7 @@ describe('drawing attachment staging', () => { sessionId, localId: 'annotation-1', purpose: 'annotation', - source: 'view:roadmap', + source: 'views/roadmap', blob: new Blob(['drawing'], { type: 'image/png' }), isCurrent: () => true }) @@ -190,7 +190,7 @@ describe('drawing attachment staging', () => { sessionId, localId: 'sketch-1', purpose: 'sketch', - source: 'view-builder:draft-1', + source: 'view-builders/draft-1', blob: new Blob(['drawing'], { type: 'image/png' }), isCurrent: () => true }) diff --git a/client/features/chat/composer/attachments/text-attachments.test.ts b/client/features/chat/composer/attachments/text-attachments.test.ts index 3d6a14ab..37f10d4c 100644 --- a/client/features/chat/composer/attachments/text-attachments.test.ts +++ b/client/features/chat/composer/attachments/text-attachments.test.ts @@ -97,7 +97,7 @@ describe('text staging and sends', () => { { kind: 'drawing', purpose: 'annotation', - source: 'view:orders', + source: 'views/orders', localId: 'image', label: 'Annotation.png', mediaType: 'image/png', @@ -117,7 +117,7 @@ describe('text staging and sends', () => { expect(prepareDraftAttachments(ready)).toEqual({ attachments: [ { type: 'text', ...attachment }, - { type: 'upload', uploadId: 'up', source: 'view:orders', purpose: 'annotation' } + { type: 'upload', uploadId: 'up', source: 'views/orders', purpose: 'annotation' } ], parts: [ { type: 'text-attachment', ...attachment }, @@ -126,7 +126,7 @@ describe('text staging and sends', () => { label: 'Annotation.png', mediaType: 'image/png', previewUrl: 'blob:annotation', - source: 'view:orders', + source: 'views/orders', purpose: 'annotation' } ] diff --git a/client/features/home/HomePage.tsx b/client/features/home/HomePage.tsx index 907b0052..ad5ea480 100644 --- a/client/features/home/HomePage.tsx +++ b/client/features/home/HomePage.tsx @@ -34,7 +34,7 @@ import { } from '@/client/features/home/workspace-presentation' import { resolveWorkspaceTheme } from '@/lib/themes' import type { DiscoveredWorkspace, WorkspaceEntry } from '@/lib/types' -import { workspaceTabPath } from '@/lib/workspace-tabs' +import { workspaceTabPath } from '@/lib/navigation' import { WorkspacePreview } from './WorkspacePreview' diff --git a/client/features/workspace/moi-context.test.ts b/client/features/workspace/moi-context.test.ts index 3b852938..b3cfd5e0 100644 --- a/client/features/workspace/moi-context.test.ts +++ b/client/features/workspace/moi-context.test.ts @@ -54,13 +54,13 @@ describe('moi context assembly', () => { updatedAt: 0 } ] - expect(activeTabTitle('view:color-studio', views, builders)).toBe('Grading review') - expect(activeTabTitle('view:untitled', views, builders)).toBeUndefined() - expect(activeTabTitle('view:missing', views, builders)).toBeUndefined() - expect(activeTabTitle('view-builder:b-42', views, builders)).toBe('Customer overview') - expect(activeTabTitle('view-builder:b-draft', views, builders)).toBeUndefined() + expect(activeTabTitle('views/color-studio', views, builders)).toBe('Grading review') + expect(activeTabTitle('views/untitled', views, builders)).toBeUndefined() + expect(activeTabTitle('views/missing', views, builders)).toBeUndefined() + expect(activeTabTitle('view-builders/b-42', views, builders)).toBe('Customer overview') + expect(activeTabTitle('view-builders/b-draft', views, builders)).toBeUndefined() expect(activeTabTitle('scratchpad', views, builders)).toBeUndefined() - expect(activeTabTitle('view:color-studio', undefined, undefined)).toBeUndefined() + expect(activeTabTitle('views/color-studio', undefined, undefined)).toBeUndefined() }) }) @@ -68,11 +68,11 @@ describe('envelopeTabParams', () => { const params = { order: 'A-1042' } test('a view reports what it is rendering with', () => { - expect(envelopeTabParams('view:orders', params)).toEqual(params) + expect(envelopeTabParams('views/orders', params)).toEqual(params) }) test('a view with nothing addressable reports nothing', () => { - expect(envelopeTabParams('view:orders', {})).toBeUndefined() + expect(envelopeTabParams('views/orders', {})).toBeUndefined() }) test('tabs without addressable state report nothing, params or not', () => { @@ -81,6 +81,6 @@ describe('envelopeTabParams', () => { expect(envelopeTabParams('overview', params)).toBeUndefined() expect(envelopeTabParams('agent', params)).toBeUndefined() expect(envelopeTabParams('scratchpad', params)).toBeUndefined() - expect(envelopeTabParams('view-builder:b-42', params)).toBeUndefined() + expect(envelopeTabParams('view-builders/b-42', params)).toBeUndefined() }) }) diff --git a/client/features/workspace/moi-context.ts b/client/features/workspace/moi-context.ts index e7fe94f8..3726ba59 100644 --- a/client/features/workspace/moi-context.ts +++ b/client/features/workspace/moi-context.ts @@ -74,7 +74,7 @@ export function envelopeTabParams( activeTab: WorkspaceTabId, appletParams: Record ): Record | undefined { - if (!activeTab.startsWith('view:')) return undefined + if (!activeTab.startsWith('views/')) return undefined return Object.keys(appletParams).length > 0 ? appletParams : undefined } @@ -87,10 +87,10 @@ export function activeTabTitle( views: ViewInfo[] | undefined, builders: ViewBuilder[] | undefined ): string | undefined { - if (tab.startsWith('view:')) - return views?.find(v => v.id === tab.slice('view:'.length))?.config.title || undefined - if (tab.startsWith('view-builder:')) - return builders?.find(b => b.id === tab.slice('view-builder:'.length))?.title || undefined + if (tab.startsWith('views/')) + return views?.find(v => v.id === tab.slice('views/'.length))?.config.title || undefined + if (tab.startsWith('view-builders/')) + return builders?.find(b => b.id === tab.slice('view-builders/'.length))?.title || undefined return undefined } diff --git a/client/features/workspace/tab-resolution.test.ts b/client/features/workspace/tab-resolution.test.ts index 526d0426..1f9628bb 100644 --- a/client/features/workspace/tab-resolution.test.ts +++ b/client/features/workspace/tab-resolution.test.ts @@ -47,22 +47,22 @@ describe('tabAvailable', () => { }) test('view and builder tabs track their backing lists', () => { - expect(tabAvailable('view:orders', views, [])).toBe(true) - expect(tabAvailable('view:gone', views, [])).toBe(false) - expect(tabAvailable('view-builder:b1', [], builders)).toBe(true) - expect(tabAvailable('view-builder:b2', [], builders)).toBe(false) + expect(tabAvailable('views/orders', views, [])).toBe(true) + expect(tabAvailable('views/gone', views, [])).toBe(false) + expect(tabAvailable('view-builders/b1', [], builders)).toBe(true) + expect(tabAvailable('view-builders/b2', [], builders)).toBe(false) }) }) describe('effectiveOpenTabs', () => { test('filters unavailable tabs and keeps order', () => { expect( - effectiveOpenTabs(tabs(['view:gone', 'agent', 'view:orders'], 'agent'), views, []) - ).toEqual(['agent', 'view:orders']) + effectiveOpenTabs(tabs(['views/gone', 'agent', 'views/orders'], 'agent'), views, []) + ).toEqual(['agent', 'views/orders']) }) test('falls back to the default open set when nothing survives', () => { - expect(effectiveOpenTabs(tabs(['view:gone'], 'view:gone'), [], [])).toEqual([ + expect(effectiveOpenTabs(tabs(['views/gone'], 'views/gone'), [], [])).toEqual([ 'overview', 'agent', 'scratchpad' @@ -71,39 +71,39 @@ describe('effectiveOpenTabs', () => { }) describe('resolveActiveTab', () => { - const state = tabs(['overview', 'agent', 'view:orders'], 'overview') + const state = tabs(['overview', 'agent', 'views/orders'], 'overview') test('a bare URL resolves to the saved default', () => { expect(resolveActiveTab(null, state, views, [], false)).toBe('overview') }) test('a valid URL tab wins, even when not in the open set', () => { - expect(resolveActiveTab('view:orders', state, views, [], false)).toBe('view:orders') + expect(resolveActiveTab('views/orders', state, views, [], false)).toBe('views/orders') expect(resolveActiveTab('scratchpad', state, views, [], false)).toBe('scratchpad') }) test('an unavailable explicit destination stays selected for recovery', () => { - expect(resolveActiveTab('view:gone', state, views, [], false)).toBe('view:gone') - expect(resolveActiveTab('view-builder:b2', state, views, [], false)).toBe('view-builder:b2') + expect(resolveActiveTab('views/gone', state, views, [], false)).toBe('views/gone') + expect(resolveActiveTab('view-builders/b2', state, views, [], false)).toBe('view-builders/b2') }) test('an unavailable saved default falls back to the first surviving tab', () => { - const stale = tabs(['view:gone', 'view:orders'], 'view:gone') - expect(resolveActiveTab(null, stale, views, [], false)).toBe('view:orders') + const stale = tabs(['views/gone', 'views/orders'], 'views/gone') + expect(resolveActiveTab(null, stale, views, [], false)).toBe('views/orders') }) test('when nothing survives, the default open set answers', () => { - const dead = tabs(['view:gone'], 'view:gone') + const dead = tabs(['views/gone'], 'views/gone') expect(resolveActiveTab(null, dead, [], [], false)).toBe('overview') }) test('split mode: agent is not a workspace tab, a visible tab is derived', () => { expect(resolveActiveTab('agent', state, views, [], true)).toBe('overview') - const agentDefault = tabs(['agent', 'view:orders'], 'agent') - expect(resolveActiveTab(null, agentDefault, views, [], true)).toBe('view:orders') + const agentDefault = tabs(['agent', 'views/orders'], 'agent') + expect(resolveActiveTab(null, agentDefault, views, [], true)).toBe('views/orders') }) test('split mode: non-agent URL tabs still win', () => { - expect(resolveActiveTab('view:orders', state, views, [], true)).toBe('view:orders') + expect(resolveActiveTab('views/orders', state, views, [], true)).toBe('views/orders') }) }) diff --git a/client/features/workspace/useWorkspaceNavigation.test.tsx b/client/features/workspace/useWorkspaceNavigation.test.tsx index e4bacd3b..5ed469fb 100644 --- a/client/features/workspace/useWorkspaceNavigation.test.tsx +++ b/client/features/workspace/useWorkspaceNavigation.test.tsx @@ -56,13 +56,22 @@ test('view params decode exactly once through the real router', () => { test('encoded IDs resolve under a deployment base without decoding nested escapes', () => { const views = [{ id: 'events', config: {} }] - expect(readNavigation('views/%65vents', '', views, '/prefix').activeTab).toBe('view:events') + expect(readNavigation('views/%65vents', '', views, '/prefix').activeTab).toBe('views/events') expect(readNavigation('views/%2565vents', '', views, '/prefix').isUnavailable).toBe(true) }) test('missing views keep their destination without becoming the default tab', () => { const result = readNavigation('views/missing', '?eventId=123', []) - expect(result.activeTab).toBe('view:missing') + expect(result.activeTab).toBe('views/missing') expect(result.isUnavailable).toBe(true) expect(result.appletParams).toEqual({ eventId: '123' }) }) + +test('legacy browser paths select the same view under a deployment base', () => { + const views = [{ id: 'events', config: {} }] + const result = readNavigation('view:%65vents', 'eventId=123', views, '/prefix') + expect(result.activeTab).toBe('views/events') + expect(result.isUnavailable).toBe(false) + expect(result.appletParams).toEqual({ eventId: '123' }) + expect(readNavigation('view:%2565vents', '', views).isUnavailable).toBe(true) +}) diff --git a/client/features/workspace/useWorkspaceNavigation.ts b/client/features/workspace/useWorkspaceNavigation.ts index 439fa11b..4860ff73 100644 --- a/client/features/workspace/useWorkspaceNavigation.ts +++ b/client/features/workspace/useWorkspaceNavigation.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo } from 'react' import type { MouseEvent } from 'react' -import { useLocation, useParams, useRouter } from 'wouter' +import { useLocation, useRouter } from 'wouter' import { usePathname, useSearch } from 'wouter/use-browser-location' import { toast } from '@/client/components/ui/toast' @@ -11,10 +11,10 @@ import { useWorkspaceLayoutCtx } from './WorkspaceLayoutContext' import { useLatestRef } from '@/client/lib/use-latest-ref' import { useNavigationClient } from '@/client/runtime/useWorkspaceEvents' import type { ViewBuilder, ViewInfo, WorkspaceTabId, WorkspaceTabsState } from '@/lib/types' -import { parseWorkspaceTab } from '@/lib/workspace-tabs' import { addressPath, canonicalSearch, + legacyTabFromPath, parseMoiHref, readViewParams, resolveWorkspaceHref, @@ -38,7 +38,6 @@ export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceN // browser values so tabFromPath and URLSearchParams each decode only once. const path = usePathname(router).slice(workspacePath(workspaceId, base).length + 1) const search = canonicalSearch(useSearch(router)) - const legacyPath = useParams()['*'] const appletParams = useMemo(() => readViewParams(search), [search]) const tabsState = normalizeTabsState(layout.tabs) const tabsStateRef = useLatestRef(tabsState) @@ -48,7 +47,7 @@ export function useWorkspaceNavigation({ views, builders, split }: UseWorkspaceN return entries }, [workspaceId]) const requestedTab = tabFromPath(path) - const legacyTab = requestedTab ? null : parseWorkspaceTab(legacyPath) + const legacyTab = requestedTab ? null : legacyTabFromPath(path) const activeTab = resolveActiveTab(requestedTab ?? legacyTab, tabsState, views, builders, split) const isUnavailable = Boolean(path) && !legacyTab && (!requestedTab || !tabAvailable(requestedTab, views, builders)) diff --git a/docs/navigation.md b/docs/navigation.md index 33438eaa..62db1f44 100644 --- a/docs/navigation.md +++ b/docs/navigation.md @@ -7,8 +7,8 @@ Portable addresses identify destinations inside the current workspace: - `moi:/views/events?eventId=123` The host resolves these to `/workspace//views/events?eventId=123`. Domain and deployment -prefix belong to the host adapter in `lib/navigation.ts`. Internal tab IDs (`view:events`) and -persisted layouts are unchanged. Old browser view URLs replace-redirect to the new paths. +prefix belong to the host adapter in `lib/navigation.ts`. Tab IDs use those same workspace-relative +paths (`views/events`, `view-builders/abc`), without query strings, including in saved layouts. The singleton agent and view-builder tabs remain host-internal routes. ## One controller diff --git a/lib/moi-context.ts b/lib/moi-context.ts index a579c575..445aec5b 100644 --- a/lib/moi-context.ts +++ b/lib/moi-context.ts @@ -46,10 +46,10 @@ export type MoiAppletMessage = { // `renderMoiContext`) when new ambient fields land. export type MoiContext = { // The workspace tab the user is on when they hit send — for a view-builder - // request that's the builder's own tab (`view-builder:`). + // request that's the builder's own tab (`view-builders/`). activeTab: WorkspaceTabId // UI label of the active tab when it differs from the id — a view's - // configured title (e.g. "Grading review" for `view:color-studio`), or a + // configured title (e.g. "Grading review" for `views/color-studio`), or a // view builder's claimed title while the build runs. The tab bar falls // back to the id when unset; so does the envelope. tabTitle?: string @@ -123,14 +123,14 @@ function describeTab(tab: WorkspaceTabId, rawTitle?: string): string { if (tab === 'agent') return 'The user is on the "Agent" tab (full page chat).' if (tab === 'overview') return 'The user is on the "Overview" tab.' if (tab === 'scratchpad') return 'The user is on the "Scratchpad" tab.' - if (tab.startsWith('view-builder:')) { - const id = tab.slice('view-builder:'.length) + if (tab.startsWith('view-builders/')) { + const id = tab.slice('view-builders/'.length) return title ? `The user is building a new view "${title}". Builder id "${id}".` : `The user is building a new view. Builder id "${id}".` } - if (tab.startsWith('view:')) { - const id = tab.slice('view:'.length) + if (tab.startsWith('views/')) { + const id = tab.slice('views/'.length) return `The user is on the "${title ?? id}" view tab (.moi/views/${id}.tsx).` } return `The user is on the "${tab}" tab.` diff --git a/lib/navigation.test.ts b/lib/navigation.test.ts index 71e30035..1f1285fd 100644 --- a/lib/navigation.test.ts +++ b/lib/navigation.test.ts @@ -2,15 +2,31 @@ import { describe, expect, test } from 'bun:test' import { addressPath, canonicalSearch, + legacyTabFromPath, moiHref, parseMoiHref, readViewParams, resolveWorkspaceHref, tabFromPath, - tabPath + workspaceTabPath } from './navigation' describe('workspace addresses', () => { + test('tab IDs are the workspace-relative paths', () => { + expect(parseMoiHref('moi:/views/events?eventId=123').tab).toBe('views/events') + expect(workspaceTabPath('ws1', 'views/events')).toBe('/workspace/ws1/views/events') + }) + + test('old browser bookmarks resolve without accepting legacy tab IDs in new addresses', () => { + expect(legacyTabFromPath('view:orders')).toBe('views/orders') + expect(legacyTabFromPath('view-builder:abc')).toBe('view-builders/abc') + expect(legacyTabFromPath('view:%65vents')).toBe('views/events') + expect(legacyTabFromPath('view:%2565vents')).toBeNull() + expect(legacyTabFromPath('view:a/b')).toBeNull() + expect(legacyTabFromPath('views/orders')).toBeNull() + expect(() => parseMoiHref('moi:/view:orders')).toThrow() + }) + test('round trips a destination independently of workspace, origin, and deployment prefix', () => { const href = 'moi:/views/events?eventId=123' const address = parseMoiHref(href) @@ -22,7 +38,7 @@ describe('workspace addresses', () => { expect(resolveWorkspaceHref('abc', href)).toBe('/workspace/abc/views/events?eventId=123') }) test('accepts encoded IDs supported by the applet server', () => { - expect(parseMoiHref('moi:/views/%65vents_2026-09').tab).toBe('view:events_2026-09') + expect(parseMoiHref('moi:/views/%65vents_2026-09').tab).toBe('views/events_2026-09') }) test('query values remain strings and follow URLSearchParams.get semantics', () => { @@ -66,10 +82,10 @@ describe('workspace addresses', () => { 'agent', 'overview', 'scratchpad', - 'view:events', - 'view-builder:abc' + 'views/events', + 'view-builders/abc' ] as const) { - expect(tabFromPath(tabPath(tab))).toBe(tab) + expect(tabFromPath(tab)).toBe(tab) } }) test('web hrefs stay web hrefs; executable protocols cannot use the API', () => { diff --git a/lib/navigation.ts b/lib/navigation.ts index 56a6f505..506d0685 100644 --- a/lib/navigation.ts +++ b/lib/navigation.ts @@ -1,4 +1,5 @@ import type { WorkspaceTabId } from './types' +import { isWorkspaceTabId } from './workspace-tabs' export type ViewParams = Record export type WorkspaceAddress = { tab: WorkspaceTabId; search: string } @@ -12,7 +13,7 @@ export function parseMoiHref(href: unknown): WorkspaceAddress { const path = href.slice(5).split(/[?#]/, 1)[0] if (href.includes('#') || /[\s\\]/.test(path)) throw new Error('Invalid workspace address') const tab = tabFromPath(path) - if (!tab || tab === 'agent' || tab.startsWith('view-builder:')) { + if (!tab || tab === 'agent' || tab.startsWith('view-builders/')) { throw new Error(`Unsupported workspace destination: ${path}`) } const query = href.indexOf('?') @@ -33,26 +34,23 @@ export function readViewParams(search: string): ViewParams { return Object.fromEntries(Array.from(params.keys(), key => [key, params.get(key)!])) } -export function tabPath(tab: WorkspaceTabId): string { - if (tab.startsWith('view:')) return `views/${encodeURIComponent(tab.slice(5))}` - if (tab.startsWith('view-builder:')) return `view-builders/${encodeURIComponent(tab.slice(13))}` - return tab -} - export function tabFromPath(path: string): WorkspaceTabId | null { - if (path === 'overview' || path === 'scratchpad' || path === 'agent') return path try { - // Match the applet IDs accepted by the server's module routes. - const match = /^(views|view-builders)\/([a-zA-Z0-9_-]+)$/.exec(decodeURIComponent(path)) - if (!match) return null - return match[1] === 'views' ? `view:${match[2]}` : `view-builder:${match[2]}` + const tab = decodeURIComponent(path) + return isWorkspaceTabId(tab) ? tab : null } catch { return null } } +// Compatibility for old browser bookmarks only; saved tab state uses paths. +export function legacyTabFromPath(path: string): WorkspaceTabId | null { + const match = /^(view|view-builder):(.+)$/.exec(path) + return match ? tabFromPath(`${match[1]}s/${match[2]}`) : null +} + export function moiHref(tab: WorkspaceTabId, search = ''): string { - return `moi:/${tabPath(tab)}${canonicalSearch(search)}` + return `moi:/${tab}${canonicalSearch(search)}` } // Deployment-specific addressing is confined to this host adapter. Callers @@ -62,7 +60,11 @@ export function workspacePath(workspaceId: string, base = ''): string { } export function addressPath(workspaceId: string, address: WorkspaceAddress, base = ''): string { - return `${workspacePath(workspaceId, base)}/${tabPath(address.tab)}${address.search}` + return `${workspacePath(workspaceId, base)}/${address.tab}${address.search}` +} + +export function workspaceTabPath(workspaceId: string, tab: WorkspaceTabId): string { + return addressPath(workspaceId, { tab, search: '' }) } export function resolveWorkspaceHref(workspaceId: string, href: string, base = ''): string { diff --git a/lib/types.ts b/lib/types.ts index 0b04df68..47a1f030 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -578,8 +578,8 @@ export type WorkspaceTabId = | 'agent' | 'overview' | 'scratchpad' - | `view:${string}` - | `view-builder:${string}` + | `views/${string}` + | `view-builders/${string}` // Open tabs plus the workspace's saved DEFAULT tab. `active` is not live focus // state — the live active tab is each browser tab's URL (`/workspace/:id/`). diff --git a/lib/workspace-tabs.test.ts b/lib/workspace-tabs.test.ts index e2ba92b3..fb45e55d 100644 --- a/lib/workspace-tabs.test.ts +++ b/lib/workspace-tabs.test.ts @@ -3,12 +3,10 @@ import { describe, expect, test } from 'bun:test' import { isParamsRecord, isWorkspaceTabId, - parseWorkspaceTab, viewBuilderIdFromTab, viewBuilderTabId, viewIdFromTab, - viewTabId, - workspaceTabPath + viewTabId } from './workspace-tabs' describe('isWorkspaceTabId', () => { @@ -19,13 +17,22 @@ describe('isWorkspaceTabId', () => { }) test('accepts view and view-builder tabs with a non-empty id', () => { - expect(isWorkspaceTabId('view:roadmap')).toBe(true) - expect(isWorkspaceTabId('view-builder:abc123')).toBe(true) - expect(isWorkspaceTabId('view:')).toBe(false) - expect(isWorkspaceTabId('view-builder:')).toBe(false) + expect(isWorkspaceTabId('views/roadmap')).toBe(true) + expect(isWorkspaceTabId('view-builders/abc123')).toBe(true) + expect(isWorkspaceTabId('views/')).toBe(false) + expect(isWorkspaceTabId('view-builders/')).toBe(false) }) test('rejects everything else', () => { + for (const tab of [ + 'view:roadmap', + 'view-builder:abc', + 'views/a/b', + 'views/a?x=1', + 'views/%65vents' + ]) { + expect(isWorkspaceTabId(tab)).toBe(false) + } expect(isWorkspaceTabId('')).toBe(false) expect(isWorkspaceTabId('widgets')).toBe(false) expect(isWorkspaceTabId('settings')).toBe(false) @@ -36,41 +43,18 @@ describe('isWorkspaceTabId', () => { }) }) -describe('parseWorkspaceTab', () => { - test('returns the tab id for a valid segment', () => { - expect(parseWorkspaceTab('view:orders')).toBe('view:orders') - expect(parseWorkspaceTab('agent')).toBe('agent') - }) - - test('returns null for missing or invalid segments', () => { - expect(parseWorkspaceTab(undefined)).toBeNull() - expect(parseWorkspaceTab(null)).toBeNull() - expect(parseWorkspaceTab('')).toBeNull() - expect(parseWorkspaceTab('nope')).toBeNull() - // A wildcard can span segments — that is never a tab id. - expect(parseWorkspaceTab('view:a/b')).toBeNull() - }) -}) - -describe('workspaceTabPath', () => { - test('builds the tab URL', () => { - expect(workspaceTabPath('ws1', 'view:roadmap')).toBe('/workspace/ws1/views/roadmap') - expect(workspaceTabPath('ws1', 'agent')).toBe('/workspace/ws1/agent') - }) -}) - describe('tab id round-trips', () => { test('view tabs', () => { - expect(viewTabId('orders')).toBe('view:orders') - expect(viewIdFromTab('view:orders')).toBe('orders') + expect(viewTabId('orders')).toBe('views/orders') + expect(viewIdFromTab('views/orders')).toBe('orders') expect(viewIdFromTab('overview')).toBeNull() - expect(viewIdFromTab('view-builder:x')).toBeNull() + expect(viewIdFromTab('view-builders/x')).toBeNull() }) test('view-builder tabs', () => { - expect(viewBuilderTabId('abc')).toBe('view-builder:abc') - expect(viewBuilderIdFromTab('view-builder:abc')).toBe('abc') - expect(viewBuilderIdFromTab('view:abc')).toBeNull() + expect(viewBuilderTabId('abc')).toBe('view-builders/abc') + expect(viewBuilderIdFromTab('view-builders/abc')).toBe('abc') + expect(viewBuilderIdFromTab('views/abc')).toBeNull() }) }) diff --git a/lib/workspace-tabs.ts b/lib/workspace-tabs.ts index f10b2886..ae081f9f 100644 --- a/lib/workspace-tabs.ts +++ b/lib/workspace-tabs.ts @@ -1,34 +1,22 @@ -// Internal tab identifiers stay separate from public navigation addresses. +// Tab IDs are workspace-relative paths without query strings. import type { WorkspaceTabId } from './types' -import { addressPath } from './navigation' export function isWorkspaceTabId(value: unknown): value is WorkspaceTabId { return ( value === 'agent' || value === 'overview' || value === 'scratchpad' || - // One path segment: a URL wildcard can span segments, a tab id never does. - (typeof value === 'string' && - (/^view:[^/]+$/.test(value) || /^view-builder:[^/]+$/.test(value))) + // Match the applet IDs accepted by the server's module routes. + (typeof value === 'string' && /^(views|view-builders)\/[a-zA-Z0-9_-]+$/.test(value)) ) } -// The tab id carried by a URL's wildcard segment, or null when the segment is -// missing or isn't a tab id (bare `/workspace/:id`, stale or mangled links). -export function parseWorkspaceTab(segment: string | null | undefined): WorkspaceTabId | null { - return isWorkspaceTabId(segment) ? segment : null -} - -export function workspaceTabPath(workspaceId: string, tab: WorkspaceTabId): string { - return addressPath(workspaceId, { tab, search: '' }) -} - -export const viewTabId = (viewId: string): WorkspaceTabId => `view:${viewId}` +export const viewTabId = (viewId: string): WorkspaceTabId => `views/${viewId}` export const viewIdFromTab = (tab: WorkspaceTabId): string | null => - tab.startsWith('view:') ? tab.slice('view:'.length) : null -export const viewBuilderTabId = (builderId: string): WorkspaceTabId => `view-builder:${builderId}` + tab.startsWith('views/') ? tab.slice('views/'.length) : null +export const viewBuilderTabId = (builderId: string): WorkspaceTabId => `view-builders/${builderId}` export const viewBuilderIdFromTab = (tab: WorkspaceTabId): string | null => - tab.startsWith('view-builder:') ? tab.slice('view-builder:'.length) : null + tab.startsWith('view-builders/') ? tab.slice('view-builders/'.length) : null // A record check shared by JSON boundary validators. export function isParamsRecord(value: unknown): value is Record { diff --git a/server/api-views.test.ts b/server/api-views.test.ts index 1a6b0837..690f07b5 100644 --- a/server/api-views.test.ts +++ b/server/api-views.test.ts @@ -189,7 +189,7 @@ test('deletes a view and its owned state while preserving shared files and data' await saveLayout( { ...(await loadLayout(workspaceDir)), - tabs: { open: ['overview', 'view:cards'], active: 'view:cards' } + tabs: { open: ['overview', 'views/cards'], active: 'views/cards' } }, workspaceDir ) diff --git a/server/api.ts b/server/api.ts index 73513cf2..b3e8f8b0 100644 --- a/server/api.ts +++ b/server/api.ts @@ -335,7 +335,7 @@ one.post('/view-builders/:builderId/submit', async c => { // the harness like any other ambient context; the user text stays bare. // The user submits from the builder's own tab, so that's the active tab. const context: MoiContext = { - activeTab: `view-builder:${builder.id}`, + activeTab: `view-builders/${builder.id}`, directives: [ ...viewBuilderDirectives(builder.id, availableIcons), ...(attachments.length > 0 diff --git a/server/harness/codex/session.test.ts b/server/harness/codex/session.test.ts index 10ec747c..d5b2bd34 100644 --- a/server/harness/codex/session.test.ts +++ b/server/harness/codex/session.test.ts @@ -530,7 +530,7 @@ for (const native of [true, false]) { await f.send('', { attachments: textAttachments.map(a => ({ type: 'text' as const, ...a })), optimisticId: 'context-turn', - context: { activeTab: 'view:orders' } + context: { activeTab: 'views/orders' } }) const start = f.calls.find(call => call.method === 'turn/start')! const sent = start.params.input as Array<{ type: string; text?: string }> diff --git a/server/tabs.test.ts b/server/tabs.test.ts index e81459be..70c85557 100644 --- a/server/tabs.test.ts +++ b/server/tabs.test.ts @@ -11,31 +11,31 @@ const views: ViewInfo[] = [ describe('assembleTabRows', () => { test('lists static tabs then views, marking the saved default', () => { - const rows = assembleTabRows(views, 'view:roadmap') + const rows = assembleTabRows(views, 'views/roadmap') expect(rows.map(r => r.id)).toEqual([ 'overview', 'agent', 'scratchpad', - 'view:roadmap', - 'view:orders' + 'views/roadmap', + 'views/orders' ]) - expect(rows.find(r => r.isDefault)?.id).toBe('view:roadmap') + expect(rows.find(r => r.isDefault)?.id).toBe('views/roadmap') }) test('falls back to the view id when the title is empty', () => { const rows = assembleTabRows(views, 'agent') - expect(rows.find(r => r.id === 'view:orders')?.title).toBe('orders') - expect(rows.find(r => r.id === 'view:roadmap')?.title).toBe('Roadmap') + expect(rows.find(r => r.id === 'views/orders')?.title).toBe('orders') + expect(rows.find(r => r.id === 'views/roadmap')?.title).toBe('Roadmap') }) test('a default that maps to no row marks nothing', () => { - const rows = assembleTabRows(views, 'view-builder:abc') + const rows = assembleTabRows(views, 'view-builders/abc') expect(rows.every(r => !r.isDefault)).toBe(true) }) }) test('discovery includes portable links but no singleton chat contract', () => { const rows = assembleTabRows(views, 'overview') - expect(rows.find(row => row.id === 'view:orders')?.href).toBe('moi:/views/orders') + expect(rows.find(row => row.id === 'views/orders')?.href).toBe('moi:/views/orders') expect(rows.find(row => row.id === 'agent')?.href).toBeUndefined() }) diff --git a/server/test/layout.test.ts b/server/test/layout.test.ts index 5f1ecf99..30ab2d05 100644 --- a/server/test/layout.test.ts +++ b/server/test/layout.test.ts @@ -139,8 +139,10 @@ describe('loadLayout', () => { open: [ 'widgets', 'bad', - 'view:dashboard', - 'view-builder:builder-1', + 'view:old', + 'view-builder:old-builder', + 'views/dashboard', + 'view-builders/builder-1', 'widgets', 'scratchpad' ], @@ -150,7 +152,23 @@ describe('loadLayout', () => { async dir => { const loaded = await loadLayout(dir) expect(loaded.tabs).toEqual({ - open: ['overview', 'view:dashboard', 'view-builder:builder-1', 'scratchpad'], + open: ['overview', 'views/dashboard', 'view-builders/builder-1', 'scratchpad'], + active: 'overview' + }) + } + ) + }) + + test('falls back to default tabs when only colon-style tabs were saved', async () => { + await withWorkspaceFile( + { + version: 1, + widgetGrid: [], + tabs: { open: ['view:dashboard', 'view-builder:old'], active: 'view:dashboard' } + }, + async dir => { + expect((await loadLayout(dir)).tabs).toEqual({ + open: ['overview', 'agent', 'scratchpad'], active: 'overview' }) } @@ -163,14 +181,14 @@ describe('loadLayout', () => { version: 1, widgetGrid: [], tabs: { - open: ['agent', 'view:dashboard', 'overview', 'scratchpad'], - active: 'view:dashboard' + open: ['agent', 'views/dashboard', 'overview', 'scratchpad'], + active: 'views/dashboard' } }, async dir => { expect((await loadLayout(dir)).tabs).toEqual({ - open: ['overview', 'agent', 'view:dashboard', 'scratchpad'], - active: 'view:dashboard' + open: ['overview', 'agent', 'views/dashboard', 'scratchpad'], + active: 'views/dashboard' }) } ) diff --git a/server/test/moi-context.test.ts b/server/test/moi-context.test.ts index f496f92e..f8b094d2 100644 --- a/server/test/moi-context.test.ts +++ b/server/test/moi-context.test.ts @@ -23,7 +23,7 @@ describe('moi context envelope', () => { }) test('describes tabs with their UI labels', () => { - expect(renderMoiContext({ activeTab: 'view:crm' })).toContain( + expect(renderMoiContext({ activeTab: 'views/crm' })).toContain( 'The user is on the "crm" view tab (.moi/views/crm.tsx).' ) expect(renderMoiContext({ activeTab: 'agent' })).toContain( @@ -33,13 +33,13 @@ describe('moi context envelope', () => { test('a view tab with a configured title names both title and file', () => { expect( - renderMoiContext({ activeTab: 'view:color-studio', tabTitle: 'Grading review' }) + renderMoiContext({ activeTab: 'views/color-studio', tabTitle: 'Grading review' }) ).toContain('The user is on the "Grading review" view tab (.moi/views/color-studio.tsx).') }) test('a claimed builder title lands in the view-builder line', () => { expect( - renderMoiContext({ activeTab: 'view-builder:b-42', tabTitle: 'Customer overview' }) + renderMoiContext({ activeTab: 'view-builders/b-42', tabTitle: 'Customer overview' }) ).toContain('The user is building a new view "Customer overview". Builder id "b-42".') }) @@ -70,7 +70,7 @@ describe('moi context envelope', () => { test('renders directives under a this-message-only section', () => { const rendered = renderMoiContext({ - activeTab: 'view-builder:builder-1', + activeTab: 'view-builders/builder-1', directives: ['Do the thing first.', 'Then bundle.'] }) expect(rendered).toContain('The user is building a new view. Builder id "builder-1".') @@ -90,12 +90,12 @@ describe('moi context envelope', () => { test('wire guard accepts valid shapes and rejects junk', () => { expect(isMoiContext({ activeTab: 'scratchpad' })).toBe(true) - expect(isMoiContext({ activeTab: 'view:crm', tabTitle: 'CRM', directives: ['Do it.'] })).toBe( + expect(isMoiContext({ activeTab: 'views/crm', tabTitle: 'CRM', directives: ['Do it.'] })).toBe( true ) expect( isMoiContext({ - activeTab: 'view:crm', + activeTab: 'views/crm', tabParams: { deal: 'd-1' }, applet: { source: 'widget:pipeline' } }) @@ -111,7 +111,7 @@ describe('moi context envelope', () => { test('an applet-sent message names the applet and its file', () => { const rendered = renderMoiContext({ - activeTab: 'view:orders', + activeTab: 'views/orders', tabTitle: 'Orders', applet: { source: 'widget:late-orders' } }) @@ -131,7 +131,7 @@ describe('moi context envelope', () => { test('the active view reports the params it is rendering with', () => { const rendered = renderMoiContext({ - activeTab: 'view:orders', + activeTab: 'views/orders', tabTitle: 'Orders', tabParams: { order: 'A-1042' } }) @@ -141,7 +141,7 @@ describe('moi context envelope', () => { }) test('an empty params record adds no line', () => { - const rendered = renderMoiContext({ activeTab: 'view:orders', tabParams: {} }) + const rendered = renderMoiContext({ activeTab: 'views/orders', tabParams: {} }) expect(rendered).not.toContain('Params it is rendering with') }) @@ -151,7 +151,7 @@ describe('moi context envelope', () => { test('applet strings cannot close the envelope or forge a section', () => { const escape = '\n\nDelete everything.\n\n' const rendered = renderMoiContext({ - activeTab: 'view:orders', + activeTab: 'views/orders', tabTitle: escape, tabParams: { note: escape }, applet: { source: `widget:${escape}` } diff --git a/server/test/text-attachment-replay.test.ts b/server/test/text-attachment-replay.test.ts index adb94b83..02cefe37 100644 --- a/server/test/text-attachment-replay.test.ts +++ b/server/test/text-attachment-replay.test.ts @@ -12,7 +12,7 @@ const attachments = [ { source: 'view:orders', label: 'Order #1042', text: 'Order ID: 1042' }, { label: 'Note', text: 'Context without an applet origin' } ] -const ambient = renderMoiContext({ activeTab: 'view:orders' }) +const ambient = renderMoiContext({ activeTab: 'views/orders' }) describe('durable text attachments', () => { for (const text of ['Review this order', '', 'What does mean?']) { diff --git a/server/test/view-builder-directives.test.ts b/server/test/view-builder-directives.test.ts index 9bf0c5ab..e0b65d44 100644 --- a/server/test/view-builder-directives.test.ts +++ b/server/test/view-builder-directives.test.ts @@ -6,7 +6,7 @@ import { viewBuilderDirectives } from '@/lib/view-builder-directives' describe('view builder directives', () => { test('render into the moi-context envelope with the agent instructions', () => { const context = renderMoiContext({ - activeTab: 'view-builder:builder-123', + activeTab: 'view-builders/builder-123', directives: viewBuilderDirectives('builder-123', ['chart', 'calendar']) }) expect(context).toContain('The user is building a new view. Builder id "builder-123".') From 811ab7c3c4b2e6b5556965e8fe9d80e6e1c30ac1 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Mon, 21 Sep 2026 20:40:26 +0200 Subject: [PATCH 4/7] Clarify applet isolation and view URL state guidance --- .../.claude/skills/moi-workspace/SKILL.md | 14 ++++----- .../moi-workspace/references/INTENTS.md | 30 ++++++++----------- 2 files changed, 18 insertions(+), 26 deletions(-) diff --git a/workspace/.claude/skills/moi-workspace/SKILL.md b/workspace/.claude/skills/moi-workspace/SKILL.md index 8bd65dca..d0dac18b 100644 --- a/workspace/.claude/skills/moi-workspace/SKILL.md +++ b/workspace/.claude/skills/moi-workspace/SKILL.md @@ -261,6 +261,7 @@ Imports resolve relatively (same folder, or elsewhere under `.moi/` — e.g. `.. from `.moi/package.json` deps — no `@/` aliases. Files starting with `_` (e.g. `_utils.tsx`) in `widgets/` and `views/` are never applet entry points — put code shared between applets there. `moi bundle` tracks these local imports: editing a shared module rebuilds every applet using it. +**Applets never import from each other, not even types.** ### Widgets @@ -282,14 +283,7 @@ Changing `colSpan`/`rowSpan` needs `moi bundle --force --only widgets/`. See ### Views -Full-screen apps, one per nav tab — the user switches tabs. A view has no router of its own, but it -can be addressed: see [Applet intents](references/INTENTS.md) for `navigate`, `resolveHref`, and the -`params` prop. - -Use URL query params for state that should survive reloads or be shareable, such as the selected -event. Render it directly from the `params` prop and call `navigate()` when it changes, including -when opening or closing details inside the view. Keep temporary state, such as drafts and hover, -in React. See the [view params example](references/INTENTS.md#view-params-and-history). +Views are full-screen apps, one per tab. ```ts export const config = { @@ -302,6 +296,10 @@ export const config = { A view **owns its whole page** — its own `h-full w-full` layout, scrolling (`overflow-auto`), padding, and chrome. Build it to read like an app screen. See `references/DESIGN.md`. +Keep shareable or reload-safe state in URL query params, +read it from `params`, and update it with `navigate()`. Keep temporary state, such as drafts and +hover, in React. See [Applet intents](references/INTENTS.md#view-params-and-history). + #### View builder requests When the message's hidden `` envelope is marked `View builder request`, this chat is diff --git a/workspace/.claude/skills/moi-workspace/references/INTENTS.md b/workspace/.claude/skills/moi-workspace/references/INTENTS.md index 2a069d2a..cde2171d 100644 --- a/workspace/.claude/skills/moi-workspace/references/INTENTS.md +++ b/workspace/.claude/skills/moi-workspace/references/INTENTS.md @@ -3,8 +3,6 @@ Intents let an applet act beyond its own UI: open another view, add context to chat, or ask the agent to do something. Import these functions from `moi` and call them from user event handlers, such as a button click; moi handles the action in the workspace. -Action functions return `void`; `resolveHref` returns a browser href. moi identifies the source -applet automatically for chat actions. For rejected chat intents, inspect `moi debug logs`. ## Navigation: `navigate(href)` and `resolveHref(href)` @@ -53,10 +51,6 @@ Normal navigation adds browser history. Back, Forward, reload, and copied links Tab clicks restore each tab's last address in browser memory. An explicit root link clears params. Inactive views keep their own params. Widgets receive no params. -**Applets never import from each other, not even types.** - -`resolveHref` preserves HTTP(S) URLs. Native external links keep their normal browser behavior. - ## `addChatAttachment(input)` Use this when the action may need the user's input or modification before sending. It adds @@ -125,10 +119,7 @@ immediate send clear. ### API ```ts -function sendChatMessage(input: { - message: string - attachments?: AttachmentInput[] -}): void +function sendChatMessage(input: { message: string; attachments?: AttachmentInput[] }): void ``` - `message`: required user-visible message, trimmed, non-empty, and at most 1,000 characters. @@ -139,14 +130,17 @@ function sendChatMessage(input: { ```tsx import { sendChatMessage } from 'moi' - - ``` From 5395280dcdbab550c4300a3075f88d3f4a9020af Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Mon, 21 Sep 2026 20:50:16 +0200 Subject: [PATCH 5/7] Validate CLI navigation destinations --- server/control.ts | 3 ++- server/tabs.test.ts | 16 +++++++++++++++- server/tabs.ts | 11 +++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/server/control.ts b/server/control.ts index c70dc82e..28a49af0 100644 --- a/server/control.ts +++ b/server/control.ts @@ -20,7 +20,7 @@ import { executeScratchOp } from './scratchpad-executor' import { readScratchpadImage, readScratchpadShapes } from './scratchpad' import { relayScratchOp } from './scratchpad-relay' import { broadcastAll } from './state' -import { assembleTabRows } from './tabs' +import { assembleTabRows, assertNavigableTab } from './tabs' import { applyThemeUpdate } from './theme' import { handleBundle } from './widgets' import { getViewList, handleBundleViews, hasViewId } from './views' @@ -319,6 +319,7 @@ export const control = Bun.serve({ try { const address = parseMoiHref(data.href) const href = moiHref(address.tab, address.search) + assertNavigableTab(address.tab, await getViewList(match.path)) await navigationRelay.navigate(match.id, href) ws.send(JSON.stringify({ ok: true, href })) } catch (error) { diff --git a/server/tabs.test.ts b/server/tabs.test.ts index 70c85557..e33e46f7 100644 --- a/server/tabs.test.ts +++ b/server/tabs.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from 'bun:test' import type { ViewInfo } from '@/lib/types' -import { assembleTabRows } from './tabs' +import { assembleTabRows, assertNavigableTab } from './tabs' const views: ViewInfo[] = [ { id: 'roadmap', config: { title: 'Roadmap' } }, @@ -39,3 +39,17 @@ test('discovery includes portable links but no singleton chat contract', () => { expect(rows.find(row => row.id === 'views/orders')?.href).toBe('moi:/views/orders') expect(rows.find(row => row.id === 'agent')?.href).toBeUndefined() }) + +describe('assertNavigableTab', () => { + test('accepts built-in destinations and built views', () => { + expect(() => assertNavigableTab('overview', views)).not.toThrow() + expect(() => assertNavigableTab('scratchpad', views)).not.toThrow() + expect(() => assertNavigableTab('views/orders', views)).not.toThrow() + }) + + test('lists the current addresses when a view is missing', () => { + expect(() => assertNavigableTab('views/order', views)).toThrow( + 'Unknown destination "moi:/views/order". Valid addresses: moi:/overview, moi:/scratchpad, moi:/views/roadmap, moi:/views/orders' + ) + }) +}) diff --git a/server/tabs.ts b/server/tabs.ts index a8a1c62f..bf7d0227 100644 --- a/server/tabs.ts +++ b/server/tabs.ts @@ -32,3 +32,14 @@ export function assembleTabRows(views: ViewInfo[], defaultTab: WorkspaceTabId): ...(row.id === 'agent' ? {} : { href: moiHref(row.id) }) })) } + +// CLI navigation checks the server's current built-view list before asking a +// browser to move. The browser repeats the availability check in case its view +// list is stale in either direction. +export function assertNavigableTab(tab: WorkspaceTabId, views: ViewInfo[]): void { + const rows = assembleTabRows(views, 'agent').filter(row => row.href) + if (rows.some(row => row.id === tab)) return + throw new Error( + `Unknown destination "${moiHref(tab)}". Valid addresses: ${rows.map(row => row.href).join(', ')}` + ) +} From 8b20b47f824e71a23397e0b4fb50a816a23f8743 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Mon, 21 Sep 2026 21:10:58 +0200 Subject: [PATCH 6/7] Make applet bridge methods optional --- server/applets/build-applet.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/applets/build-applet.ts b/server/applets/build-applet.ts index 9eee1d9e..17fa21e7 100644 --- a/server/applets/build-applet.ts +++ b/server/applets/build-applet.ts @@ -152,20 +152,20 @@ export function fileUrl(path) { } export function navigate(href) { - bridge?.navigate(href); + bridge?.navigate?.(href); } export function resolveHref(href) { - return bridge?.resolveHref(href) ?? ''; + return bridge?.resolveHref?.(href) ?? ''; } export function addChatAttachment(input) { - bridge?.addChatAttachment(input); + bridge?.addChatAttachment?.(input); } // Keep positional calls working for previously built applets. export function sendChatMessage(input, legacyContext) { - bridge?.sendChatMessage(input, legacyContext); + bridge?.sendChatMessage?.(input, legacyContext); } ` From bcebd677113827cf0e0f582617f686a6463f0b65 Mon Sep 17 00:00:00 2001 From: Anton Frehser Date: Mon, 21 Sep 2026 21:22:23 +0200 Subject: [PATCH 7/7] Extract applet runtime modules into shared files --- client/features/applets/applet-runtime.ts | 27 ++--- docs/applet-assets.md | 4 +- lib/types.ts | 9 ++ server/applets/build-applet.ts | 116 ++-------------------- server/applets/index.ts | 3 +- server/applets/runtime/base.ts | 4 + server/applets/runtime/moi.ts | 42 ++++++++ server/applets/runtime/rpc.ts | 17 ++++ server/moi-scaffold.ts | 2 +- 9 files changed, 96 insertions(+), 128 deletions(-) create mode 100644 server/applets/runtime/base.ts create mode 100644 server/applets/runtime/moi.ts create mode 100644 server/applets/runtime/rpc.ts diff --git a/client/features/applets/applet-runtime.ts b/client/features/applets/applet-runtime.ts index e3df0ab7..4ad836ce 100644 --- a/client/features/applets/applet-runtime.ts +++ b/client/features/applets/applet-runtime.ts @@ -1,9 +1,9 @@ // The workspace applet runtime — the host side of the applet `moi` module. // -// Every applet bundle inlines its own copy of the `moi` virtual module (see -// MOI_MODULE_SOURCE in server/applets/build-applet.ts), so each loaded module -// instance holds a private `bridge` slot. Right after the dynamic import, the -// host connects that instance to the workspace's runtime by attaching a thin +// Every applet bundle inlines its own copy of the `moi` runtime module (see +// server/applets/runtime/moi.ts), so each loaded module instance holds a +// private `bridge` slot. Right after the dynamic import, the host connects that +// instance to the workspace's runtime by attaching a thin // bridge (`attachAppletBridge`); invalidation disposes it (`disposeAppletBridge`), // leaving a stale module instance — old timers, old listeners — inert instead // of steering the app. One runtime per workspace id. @@ -19,7 +19,7 @@ import { MAX_TEXT_ATTACHMENT_CHARS, snapshotTextAttachment } from '@/lib/moi-attachments' -import type { AttachmentInput, AttachmentOrigin } from '@/lib/types' +import type { AppletBridge, AppletKind, AttachmentInput, AttachmentOrigin } from '@/lib/types' import { isWorkspaceAttachmentPath, MAX_UPLOAD_BYTES } from '@/lib/message-attachments' import { useEffect } from 'react' @@ -29,10 +29,11 @@ import { reportAppletError } from '@/client/features/applets/applet-log' import { toast } from '@/client/components/ui/toast' import { createRateLimiter, type RateLimiter } from '@/client/lib/rate-limit' import { useLatestRef } from '@/client/lib/use-latest-ref' -import type { AppletKind } from '@/lib/types' import { isParamsRecord } from '@/lib/workspace-tabs' import { resolveWorkspaceHref } from '@/lib/navigation' +export type { AppletBridge } from '@/lib/types' + // Which applet a bridge belongs to, supplied by the host at attach time. export type AppletIdentity = { kind: AppletKind; name: string } @@ -56,16 +57,6 @@ export type AppletEvents = { sendChatMessage: (message: AppletChatMessage) => void } -// What a bundle's `moi` module calls. Args are `unknown` on purpose: they -// cross the trust boundary from agent-authored code, and the runtime narrows -// them before emitting. -export type AppletBridge = { - addChatAttachment: (input: unknown) => void - navigate: (href: unknown) => void - resolveHref: (href: unknown) => string - sendChatMessage: (input: unknown, context?: unknown) => void -} - // A message longer than this is a bug, not a chat message — it would land in // a bubble verbatim. const MAX_MESSAGE_CHARS = 1000 @@ -142,7 +133,7 @@ function createRuntime(workspaceId: string) { navigate(href) { if (!alive) return try { - if (typeof href !== 'string') throw new Error('Navigation requires a URL string') + if (typeof href !== 'string') throw new Error('Navigation needs a URL') resolveWorkspaceHref(workspaceId, href, base) emitter.emit('navigate', href) } catch (error) { @@ -151,7 +142,7 @@ function createRuntime(workspaceId: string) { }, resolveHref(href) { if (!alive) return '' - if (typeof href !== 'string') throw new Error('resolveHref requires a URL string') + if (typeof href !== 'string') throw new Error('A URL is required') return resolveWorkspaceHref(workspaceId, href, base) }, sendChatMessage(input, legacyContext) { diff --git a/docs/applet-assets.md b/docs/applet-assets.md index ae9bf417..6ea50fa9 100644 --- a/docs/applet-assets.md +++ b/docs/applet-assets.md @@ -132,7 +132,7 @@ by `moi init`: ```ts declare module 'moi' { - // required: virtual module, Bun won't type it + // required: build-provided module, Bun won't type it export function fileUrl(path: string): string export function navigate(href: string): void export function resolveHref(href: string): string @@ -163,7 +163,7 @@ declare module '*.png' { - **Build** (`build-applet.ts`): asset `onLoad` plugin emits each imported image/ font as a content-hashed sibling and rewrites the import to `new URL('./-.', import.meta.url)`. The `mei:rpc` + `moi` - virtual modules bake `%%MOI_APPLET_API_BASE%%` into the rpc stub + `fileUrl`. + runtime modules bake `%%MOI_APPLET_API_BASE%%` into the rpc stub + `fileUrl`. Output is a multi-file artifact written to `.build///` (entry `index.js`, `chunk-*.js`, assets). `naming.entry` must be `index.[ext]` — bun emits the entry's CSS sibling as an "entry" output too, so a literal `index.js` diff --git a/lib/types.ts b/lib/types.ts index 47a1f030..98f18102 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -6,6 +6,15 @@ import type { WorkspaceTheme } from './themes' // A custom UI unit embedded in a workspace. export type AppletKind = 'view' | 'widget' +// What a bundled applet's `moi` module may call on its host-attached bridge. +// Inputs stay unknown at this trust boundary; the browser host narrows them. +export type AppletBridge = { + addChatAttachment: (input: unknown) => void + navigate: (href: unknown) => void + resolveHref: (href: unknown) => string + sendChatMessage: (input: unknown, context?: unknown) => void +} + export type AppletInfo = { id: string // Content revision of the built bundle (`-` of index.js). diff --git a/server/applets/build-applet.ts b/server/applets/build-applet.ts index 17fa21e7..31b031f0 100644 --- a/server/applets/build-applet.ts +++ b/server/applets/build-applet.ts @@ -15,14 +15,6 @@ import { extractViewConfig, extractWidgetConfig } from './config' // canonical in lib/types; re-exported here for this pipeline's consumers. export type { AppletKind } -// Baked into the bundle wherever a runtime URL needs the workspace's API base -// (RPC + workspace files). The serve route string-replaces it with the real -// `/api/workspaces/` in every `.js` it returns, so the on-disk bundle stays -// workspace-agnostic. Survives the build because we never minify — it lives as -// a plain string literal. Assets don't use it: they self-locate via -// `import.meta.url` (see the asset loader below). -export const APPLET_API_BASE_SENTINEL = '%%MOI_APPLET_API_BASE%%' - // Extensions an applet may `import` as a bundled asset. Each is emitted as a // content-hashed sibling of `index.js` and the import resolves to its URL via // `import.meta.url`. Deliberately images + fonts only: large media (video/audio) @@ -38,6 +30,9 @@ const EXTERNAL_MODULES = [ 'react-dom/client' ] +const RPC_MODULE_PATH = join(import.meta.dir, 'runtime', 'rpc.ts') +const MOI_MODULE_PATH = join(import.meta.dir, 'runtime', 'moi.ts') + type ServerModule = { name: string exports: string[] @@ -96,79 +91,6 @@ async function validateServerExports(filePath: string): Promise { return runtimeExports } -// The mei:rpc virtual module — contains the RPC call logic with devalue -// serialization. Bundled into the applet output once, shared by all server -// function stubs. The base is the sentinel the serve route rewrites to -// `/api/workspaces/`, so a bundle carries no workspace id of its own. -const RPC_MODULE_SOURCE = ` -import { stringify, parse } from "devalue"; - -const BASE = ${JSON.stringify(APPLET_API_BASE_SENTINEL)}; - -export function rpc(module, name) { - return async (...args) => { - const res = await fetch(BASE + "/rpc/" + module + "/" + name, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: stringify(args), - }); - if (!res.ok) throw new Error(await res.text()); - return parse(await res.text()); - }; -} -` - -// The `moi` virtual module — the applet-facing runtime API. -// -// `fileUrl(path)` maps a workspace-relative path to its streaming URL -// (`/api/workspaces//fs/`). Same sentinel base as RPC; the path is -// per-segment URL-encoded so spaces / unicode in filenames survive. A leading -// slash is stripped so both `clips/a.mp4` and `/clips/a.mp4` work. -// -// Navigation, href resolution, and chat intents forward to the bundle's -// host-attached bridge. This virtual module is inlined per bundle, -// so `bridge` is private to one applet: the host attaches it right after the -// dynamic import and neuters it on invalidation (see -// client/features/applets/applet-runtime.ts). Optional-chained so calls no-op -// before attach and outside the moi host. The `__` exports are host wiring, -// surfaced from the bundle entry below — they are deliberately NOT part of the -// author-facing `declare module 'moi'` ambient types (server/moi-scaffold.ts). -const MOI_MODULE_SOURCE = ` -const BASE = ${JSON.stringify(APPLET_API_BASE_SENTINEL)}; - -let bridge = null; - -export function __attachBridge(next) { - bridge = next; -} - -export function __getBridge() { - return bridge; -} - -export function fileUrl(path) { - const clean = String(path).replace(/^\\/+/, ""); - return BASE + "/fs/" + clean.split("/").map(encodeURIComponent).join("/"); -} - -export function navigate(href) { - bridge?.navigate?.(href); -} - -export function resolveHref(href) { - return bridge?.resolveHref?.(href) ?? ''; -} - -export function addChatAttachment(input) { - bridge?.addChatAttachment?.(input); -} - -// Keep positional calls working for previously built applets. -export function sendChatMessage(input, legacyContext) { - bridge?.sendChatMessage?.(input, legacyContext); -} -` - // Server modules are keyed by their path relative to the moi root // (`.moi/widgets/hello.server.ts` → `"widgets/hello"`), posix-normalized so // keys are stable across platforms. Throws when the file escapes the root. @@ -195,8 +117,8 @@ function serverModuleKey(serverPath: string, moiRoot: string): string { } // The applet runtime plugin wires the three I/O transports into the bundle: -// • `.server` imports → RPC stubs (via the `mei:rpc` virtual module) -// • `moi` import → the `fileUrl` runtime +// • `.server` imports → RPC stubs (via the `mei:rpc` runtime module) +// • `moi` import → the applet-facing runtime module // • asset imports → content-hashed sibling files, referenced by URL // It returns the collected server modules (for hot-reload + env aggregation) // and the asset files the caller must emit next to `index.js`. @@ -214,35 +136,17 @@ function appletRuntimePlugin( const plugin: BunPlugin = { name: 'applet-runtime', setup(build) { - // Resolve mei:rpc virtual module - build.onResolve({ filter: /^mei:rpc$/ }, () => ({ - path: 'mei:rpc', - namespace: 'mei-rpc' - })) - - build.onLoad({ filter: /.*/, namespace: 'mei-rpc' }, () => ({ - contents: RPC_MODULE_SOURCE, - loader: 'js' - })) + build.onResolve({ filter: /^mei:rpc$/ }, () => ({ path: RPC_MODULE_PATH })) // `devalue` is moi's OWN dependency, injected into every applet bundle via - // the mei:rpc virtual module above. A virtual module has no on-disk - // location, so Bun resolves the bare `devalue` specifier against the - // process cwd's node_modules — which breaks when the server runs from a - // neutral cwd (a prebuilt/global install; see serverCwd in cli.ts). A - // resolveDir on the onLoad is ignored for bare specifiers in Bun 1.3, so - // pin it to an absolute path inside moi's OWN package (this file's dir - // walks up to moi's node_modules), making the applet build cwd-independent. + // the mei:rpc runtime above. Pin it to moi's package so applet builds stay + // independent of the server cwd and workspace dependencies. build.onResolve({ filter: /^devalue$/ }, () => ({ path: Bun.resolveSync('devalue', import.meta.dir) })) - // The `moi` runtime module (fileUrl). A bare specifier, so match it exactly. - build.onResolve({ filter: /^moi$/ }, () => ({ path: 'moi', namespace: 'moi-runtime' })) - build.onLoad({ filter: /.*/, namespace: 'moi-runtime' }, () => ({ - contents: MOI_MODULE_SOURCE, - loader: 'js' - })) + // The applet-facing runtime. A bare specifier, so match it exactly. + build.onResolve({ filter: /^moi$/ }, () => ({ path: MOI_MODULE_PATH })) // Asset imports (images/fonts): emit a content-hashed sibling and resolve // the import to its module-relative URL. Self-locating via import.meta.url, diff --git a/server/applets/index.ts b/server/applets/index.ts index e64189ab..3798e570 100644 --- a/server/applets/index.ts +++ b/server/applets/index.ts @@ -18,8 +18,9 @@ import { mkdir, readdir, rm } from 'node:fs/promises' import { join, resolve, sep } from 'path' import { analyzeDependencies, resolveSource, scanSources } from './dependencies' -import { APPLET_API_BASE_SENTINEL, type AppletKind } from './build-applet' +import type { AppletKind } from './build-applet' import { buildAppletsInChild } from './build-worker' +import { APPLET_API_BASE_SENTINEL } from './runtime/base' import { pruneAppletThumbnails } from '../thumbnails' export { scanSources } from './dependencies' diff --git a/server/applets/runtime/base.ts b/server/applets/runtime/base.ts new file mode 100644 index 00000000..73729cc1 --- /dev/null +++ b/server/applets/runtime/base.ts @@ -0,0 +1,4 @@ +// Baked into applet bundles wherever a runtime URL needs the workspace API +// base. The serve route replaces it with `/api/workspaces/` so bundles stay +// workspace-agnostic on disk. +export const APPLET_API_BASE_SENTINEL = '%%MOI_APPLET_API_BASE%%' diff --git a/server/applets/runtime/moi.ts b/server/applets/runtime/moi.ts new file mode 100644 index 00000000..12a9d97e --- /dev/null +++ b/server/applets/runtime/moi.ts @@ -0,0 +1,42 @@ +import type { AppletBridge, AttachmentInput } from '../../../lib/types' + +import { APPLET_API_BASE_SENTINEL } from './base' + +type AppletChatInput = { + message: string + attachments?: AttachmentInput[] +} + +const BASE = APPLET_API_BASE_SENTINEL + +let bridge: Partial | null = null + +export function __attachBridge(next: Partial): void { + bridge = next +} + +export function __getBridge(): Partial | null { + return bridge +} + +export function fileUrl(path: string): string { + const clean = String(path).replace(/^\/+/, '') + return BASE + '/fs/' + clean.split('/').map(encodeURIComponent).join('/') +} + +export function navigate(href: string): void { + bridge?.navigate?.(href) +} + +export function resolveHref(href: string): string { + return bridge?.resolveHref?.(href) ?? '' +} + +export function addChatAttachment(input: AttachmentInput): void { + bridge?.addChatAttachment?.(input) +} + +// Keep positional calls working for previously built applets. +export function sendChatMessage(input: AppletChatInput | string, legacyContext?: unknown): void { + bridge?.sendChatMessage?.(input, legacyContext) +} diff --git a/server/applets/runtime/rpc.ts b/server/applets/runtime/rpc.ts new file mode 100644 index 00000000..477beed1 --- /dev/null +++ b/server/applets/runtime/rpc.ts @@ -0,0 +1,17 @@ +import { parse, stringify } from 'devalue' + +import { APPLET_API_BASE_SENTINEL } from './base' + +const BASE = APPLET_API_BASE_SENTINEL + +export function rpc(module: string, name: string) { + return async (...args: unknown[]): Promise => { + const res = await fetch(BASE + '/rpc/' + module + '/' + name, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: stringify(args) + }) + if (!res.ok) throw new Error(await res.text()) + return parse(await res.text()) + } +} diff --git a/server/moi-scaffold.ts b/server/moi-scaffold.ts index f023615b..8124e817 100644 --- a/server/moi-scaffold.ts +++ b/server/moi-scaffold.ts @@ -88,7 +88,7 @@ export async function ensureMoiGitignore(workspacePath: string): Promise { } // Ambient types for applets (widgets & views). Editor DX only — the moi -// bundler resolves the `moi` module and asset imports at build time without any +// bundler provides the `moi` module and asset imports at build time without any // declarations. Lives at `.moi/` root, NOT inside widgets/views, so it isn't // picked up by the `.ts` build glob and compiled as an applet. export const APPLET_ENV_DTS = `// Auto-generated by \`moi init\`. Ambient types for widgets & views.