diff --git a/.changeset/adr-0057-p3a-chatdock.md b/.changeset/adr-0057-p3a-chatdock.md new file mode 100644 index 0000000000..8257c7e4fd --- /dev/null +++ b/.changeset/adr-0057-p3a-chatdock.md @@ -0,0 +1,28 @@ +--- +"@object-ui/app-shell": minor +"@object-ui/layout": minor +--- + +feat(console-ai): ChatDock — right-docked AI rail behind a default-off flag (ADR-0057 P3a) + +Stands up the ADR-0057 P3 docked rail as an ADDITIVE, DEFAULT-OFF shell: until an +operator sets `features.chatDock`, nothing changes and the FAB stays the +canonical entry. + +- `@object-ui/layout`: `AppShell` gains an optional `rightRail` prop, rendered as + a flex sibling of the main content so the rail REFLOWS the content beside it + (VS Code / Cursor idiom), not overlaying it. Absent → unchanged single-pane. +- `@object-ui/app-shell`: new `ChatDock` — a collapsible, resizable right rail + that reuses the shared `ChatPane` engine over the P1 `(user, app, product=ask)` + conversation (the same ambient thread the FAB/`/ai` shows; it's a VIEW, not a + new conversation). Default COLLAPSED (a fixed edge launcher → zero layout cost + until invoked); ⌘/Ctrl+Shift+I toggles it. Gated on `useAiSurfaceEnabled` AND + the flag, so OSS / no-seat runtimes render nothing. +- `runtime-config`: `chatDock?` rollout flag, parsed default-OFF (opt-in only). + +Live-verified with the flag forced on: the launcher expands to a rail rendering +the ask chat, the dashboard content reflows narrower beside it, and collapse +restores the launcher. Unit-tested: width clamp, the composer-safe shortcut +matcher (⌘⇧I, no collision with the ⌘⇧O/S page shortcuts), and the flag's +default-off/opt-in parse. FAB retirement (P3b) and `/ai`-as-maximized-dock + +Studio reflow (P3c) follow. diff --git a/packages/app-shell/src/layout/ChatDock.tsx b/packages/app-shell/src/layout/ChatDock.tsx new file mode 100644 index 0000000000..182f974eae --- /dev/null +++ b/packages/app-shell/src/layout/ChatDock.tsx @@ -0,0 +1,237 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * ADR-0057 P3a — the ChatDock: the console AI chat rendered as a right-docked, + * collapsible, resizable rail (the VS Code / Cursor idiom) that REFLOWS the main + * content beside it (via {@link AppShell}'s `rightRail`), rather than overlaying + * it like the FAB. Additive and DEFAULT-OFF (`features.chatDock`): until an + * operator opts in, none of this renders and the FAB stays the canonical entry. + * + * It reuses the shared {@link ChatPane} engine over the P1 `(user, app, product)` + * conversation — the same thread the full-page `/ai` surface shows — so the dock + * is a VIEW, not a new conversation. P3b later retires the FAB into this dock's + * launcher; P3c makes `/ai` the dock maximized. + */ +import * as React from 'react'; +import { cn, Button } from '@object-ui/components'; +import { MessagesSquare, PanelRightClose } from 'lucide-react'; +import { useObjectTranslation } from '@object-ui/i18n'; +import { useAgents } from '@object-ui/plugin-chatbot'; +import { ChatPane, resolveApiBase, type PendingFirstMessage } from '../console/ai/AiChatPage'; +import { useChatConversation } from '../hooks'; +import { chatConversationScope, chatProductOfAgent } from '../hooks/chatScope'; +import { resolveSurfaceAgent } from '../hooks/surfaceAgent'; +import { getRuntimeConfig } from '../runtime-config'; +import { + clampDockWidth, + DOCK_DEFAULT_WIDTH, + DOCK_WIDTH_STORAGE_KEY, +} from './chatDockState'; + +function readStoredWidth(): number { + try { + const raw = window.localStorage.getItem(DOCK_WIDTH_STORAGE_KEY); + const n = raw ? Number.parseInt(raw, 10) : NaN; + return Number.isFinite(n) && n > 0 ? n : DOCK_DEFAULT_WIDTH; + } catch { + return DOCK_DEFAULT_WIDTH; + } +} + +export interface ChatDockState { + expanded: boolean; + width: number; + dragging: boolean; + toggle: () => void; + expand: () => void; + collapse: () => void; + /** Pointer-down on the rail's left-edge resize handle. */ + onResizePointerDown: (e: React.PointerEvent) => void; +} + +/** + * ChatDock open/collapsed + width state. Default COLLAPSED — the dock has zero + * layout cost until invoked (preserving the FAB's virtue of not reflowing dense + * data grids). Width persists to localStorage; the drag anchors the rail's LEFT + * edge (dragging left widens it). All DOM reads happen in handlers, never render. + */ +export function useChatDockState(): ChatDockState { + const [expanded, setExpanded] = React.useState(false); + const [width, setWidth] = React.useState(() => readStoredWidth()); + const [dragging, setDragging] = React.useState(false); + + const onResizePointerDown = React.useCallback( + (e: React.PointerEvent) => { + e.preventDefault(); + const startX = e.clientX; + const startWidth = width; + setDragging(true); + const onMove = (ev: PointerEvent) => { + // Rail is right-anchored: dragging the left edge LEFT (clientX shrinks) + // widens it, so the delta is (start − current). + const next = clampDockWidth(startWidth + (startX - ev.clientX), window.innerWidth); + setWidth(next); + }; + const onUp = (ev: PointerEvent) => { + const final = clampDockWidth(startWidth + (startX - ev.clientX), window.innerWidth); + try { + window.localStorage.setItem(DOCK_WIDTH_STORAGE_KEY, String(Math.round(final))); + } catch { + /* private mode — width just won't persist */ + } + setDragging(false); + window.removeEventListener('pointermove', onMove); + window.removeEventListener('pointerup', onUp); + }; + window.addEventListener('pointermove', onMove); + window.addEventListener('pointerup', onUp); + }, + [width], + ); + + const toggle = React.useCallback(() => setExpanded((v) => !v), []); + const expand = React.useCallback(() => setExpanded(true), []); + const collapse = React.useCallback(() => setExpanded(false), []); + + return { expanded, width, dragging, toggle, expand, collapse, onResizePointerDown }; +} + +interface ChatDockConversationProps { + userId: string | undefined; + apiBase: string; +} + +/** + * The dock's chat body — resolves the ambient `ask` agent + its P1 conversation + * and mounts the shared {@link ChatPane}. Mirrors StudioAiCopilot's minimal + * embed, but on the `default` (ask) surface with an app-less scope, so it shows + * the console's ambient assistant thread. Renders nothing when the AI catalog is + * empty (OSS / no seat). + */ +function ChatDockConversation({ userId, apiBase }: ChatDockConversationProps) { + const { agents, isLoading, error } = useAgents({ apiBase }); + const activeAgent = React.useMemo( + () => + resolveSurfaceAgent('default', { + agents, + aiStudioEnabled: getRuntimeConfig().features.aiStudio !== false, + }), + [agents], + ); + const chatApi = activeAgent + ? `${apiBase}/agents/${encodeURIComponent(activeAgent)}/chat` + : undefined; + const scope = activeAgent + ? chatConversationScope({ appId: undefined, product: chatProductOfAgent(activeAgent) }) + : undefined; + const { conversationId, initialMessages } = useChatConversation({ + userId: activeAgent ? userId : undefined, + scope, + apiBase, + activeId: undefined, + forceNew: false, + }); + const pendingFirstMessageRef = React.useRef(null); + + // OSS / no AI seat → the whole dock body is inert (the launcher is gated too). + if (!isLoading && agents.length === 0) return null; + + return ( + {}} + onShare={() => {}} + showDebug={false} + /> + ); +} + +export interface ChatDockPanelProps { + dock: ChatDockState; + userId: string | undefined; + apiBase?: string; +} + +/** + * The expanded rail — pass into {@link AppShell} `rightRail` so it reflows the + * main content. Only render this when `dock.expanded` (the caller decides), so a + * collapsed dock contributes no flex child. + */ +export function ChatDockPanel({ dock, userId, apiBase: apiBaseProp }: ChatDockPanelProps) { + const { t } = useObjectTranslation(); + const apiBase = React.useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]); + return ( + + ); +} + +export interface ChatDockLauncherProps { + onExpand: () => void; +} + +/** + * The collapsed affordance — a fixed edge button that expands the dock. Rendered + * only while collapsed, so it never overlaps the expanded rail. (P3b will fold + * the FAB into this launcher; for P3a they coexist behind the flag.) + */ +export function ChatDockLauncher({ onExpand }: ChatDockLauncherProps) { + const { t } = useObjectTranslation(); + return ( + + ); +} diff --git a/packages/app-shell/src/layout/ConsoleLayout.tsx b/packages/app-shell/src/layout/ConsoleLayout.tsx index 86fd17a505..569a38eaeb 100644 --- a/packages/app-shell/src/layout/ConsoleLayout.tsx +++ b/packages/app-shell/src/layout/ConsoleLayout.tsx @@ -15,6 +15,8 @@ import { AppShell } from '@object-ui/layout'; // shiki, streamdown, mermaid, @ai-sdk, ~20MB) only downloads on first // hover/click. See ConsoleChatbotFab.tsx. import { ConsoleChatbotFab } from './ConsoleChatbotFab'; +import { useChatDockState, ChatDockPanel, ChatDockLauncher } from './ChatDock'; +import { matchChatDockShortcut } from './chatDockState'; import { DraftPreviewBar } from '../preview/DraftPreviewBar'; import { UnpublishedAppBar } from '../preview/UnpublishedAppBar'; import { UnifiedSidebar } from './UnifiedSidebar'; @@ -25,7 +27,7 @@ import { useAiSurfaceEnabled } from '../hooks/useAiSurface'; import { useNavigationContext } from '../context/NavigationContext'; import { CommandPaletteProvider } from '../context/CommandPaletteProvider'; import { resolveI18nLabel } from '../utils'; -import { getProductName } from '../runtime-config'; +import { getProductName, getRuntimeConfig } from '../runtime-config'; import type { ConnectionState } from '@object-ui/data-objectstack'; /** Minimal object shape used by the chatbot context */ @@ -78,12 +80,30 @@ export function ConsoleLayout({ // nowhere else; it now lives in exactly one place. const { setContext, setCurrentAppName } = useNavigationContext(); + // ADR-0057 P3a — the right-docked chat rail. DEFAULT OFF: gated on the + // `chatDock` rollout flag AND the same AI-surface gate as the FAB, so it is + // strictly additive and renders nothing on OSS / opt-out runtimes. + const dock = useChatDockState(); + const dockEnabled = showChatbot && getRuntimeConfig().features.chatDock === true; + // Set navigation context to 'app' when this layout mounts useEffect(() => { setContext('app'); setCurrentAppName(activeAppName); }, [setContext, setCurrentAppName, activeAppName]); + // ⌘/Ctrl+Shift+I toggles the dock (composer-safe; see matchChatDockShortcut). + useEffect(() => { + if (!dockEnabled) return; + const onKeyDown = (e: KeyboardEvent) => { + if (matchChatDockShortcut(e) !== 'toggle') return; + e.preventDefault(); + dock.toggle(); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [dockEnabled, dock]); + return ( // One shared, URL-addressable command-palette open state for both the // AppHeader trigger (navbar) and the CommandPalette (children) — ADR-0054. @@ -107,6 +127,9 @@ export function ConsoleLayout({ /> } className="!p-0 overflow-y-auto overflow-x-hidden bg-muted/5" + rightRail={ + dockEnabled && dock.expanded ? : undefined + } branding={ activeApp?.branding ? { @@ -144,6 +167,11 @@ export function ConsoleLayout({ userId={userId} /> )} + + {/* ADR-0057 P3a — collapsed dock affordance (edge launcher). Shown only + when the dock is enabled and collapsed; expanding renders the rail via + AppShell `rightRail` above. */} + {dockEnabled && !dock.expanded && } diff --git a/packages/app-shell/src/layout/__tests__/chatDockState.test.ts b/packages/app-shell/src/layout/__tests__/chatDockState.test.ts new file mode 100644 index 0000000000..3312cb4857 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/chatDockState.test.ts @@ -0,0 +1,50 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * ADR-0057 P3a — ChatDock pure state: the width clamp keeps neither pane from + * collapsing, and the toggle shortcut is composer-safe (⌘/Ctrl+Shift+I). + */ +import { describe, it, expect } from 'vitest'; +import { + clampDockWidth, + matchChatDockShortcut, + DOCK_MIN_WIDTH, + DOCK_CONTENT_MIN_WIDTH, +} from '../chatDockState'; + +describe('clampDockWidth', () => { + it('never narrower than the minimum', () => { + expect(clampDockWidth(100, 1600)).toBe(DOCK_MIN_WIDTH); + expect(clampDockWidth(DOCK_MIN_WIDTH - 1, 1600)).toBe(DOCK_MIN_WIDTH); + }); + + it('never so wide the main content drops below its minimum', () => { + const container = 1200; + const max = container - DOCK_CONTENT_MIN_WIDTH; + expect(clampDockWidth(9999, container)).toBe(max); + expect(clampDockWidth(max - 20, container)).toBe(max - 20); + }); + + it('skips the upper bound when the container is unmeasured', () => { + expect(clampDockWidth(4000, 0)).toBe(4000); + }); +}); + +describe('matchChatDockShortcut', () => { + const base = { key: 'i', metaKey: false, ctrlKey: false, shiftKey: false, altKey: false }; + it('matches ⌘/Ctrl+Shift+I', () => { + expect(matchChatDockShortcut({ ...base, metaKey: true, shiftKey: true })).toBe('toggle'); + expect(matchChatDockShortcut({ ...base, ctrlKey: true, shiftKey: true })).toBe('toggle'); + expect(matchChatDockShortcut({ ...base, key: 'I', metaKey: true, shiftKey: true })).toBe('toggle'); + }); + it('is composer-safe — needs the ⌘/Ctrl+Shift modifier, no bare I', () => { + expect(matchChatDockShortcut({ ...base })).toBeNull(); + expect(matchChatDockShortcut({ ...base, metaKey: true })).toBeNull(); // no Shift + expect(matchChatDockShortcut({ ...base, metaKey: true, shiftKey: true, altKey: true })).toBeNull(); + }); + it('does not collide with the AI-chat page shortcuts (O / S)', () => { + expect(matchChatDockShortcut({ ...base, key: 'o', metaKey: true, shiftKey: true })).toBeNull(); + expect(matchChatDockShortcut({ ...base, key: 's', metaKey: true, shiftKey: true })).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/layout/chatDockState.ts b/packages/app-shell/src/layout/chatDockState.ts new file mode 100644 index 0000000000..668a77dcd1 --- /dev/null +++ b/packages/app-shell/src/layout/chatDockState.ts @@ -0,0 +1,49 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * ADR-0057 P3a — pure state helpers for the ChatDock (the right-docked console + * AI rail). Kept dependency-free so the width math and the keyboard matcher are + * unit-testable without React or the DOM. + */ + +export const DOCK_WIDTH_STORAGE_KEY = 'ai-chat-dock-width'; +/** Default rail width (px). */ +export const DOCK_DEFAULT_WIDTH = 420; +/** The rail never narrower than this. */ +export const DOCK_MIN_WIDTH = 340; +/** The main content always keeps at least this much room (caps rail growth). */ +export const DOCK_CONTENT_MIN_WIDTH = 520; +/** Keyboard resize step (px) when the divider is focused. */ +export const DOCK_KEYBOARD_STEP = 24; + +/** + * Clamp a desired rail width so neither pane collapses: at least + * {@link DOCK_MIN_WIDTH}, and never so wide that the main content drops below + * {@link DOCK_CONTENT_MIN_WIDTH}. `containerWidth <= 0` (unmeasured) skips the + * upper bound. Pure + exported for tests. + */ +export function clampDockWidth(desired: number, containerWidth: number): number { + const upper = + containerWidth > 0 ? Math.max(DOCK_MIN_WIDTH, containerWidth - DOCK_CONTENT_MIN_WIDTH) : Infinity; + return Math.min(Math.max(desired, DOCK_MIN_WIDTH), upper); +} + +export type ChatDockShortcut = 'toggle'; + +/** + * Match a keydown to the ChatDock toggle — ⌘/Ctrl+Shift+I (the ADR's ⌘I idiom, + * made composer-safe with Shift so ordinary typing can't produce it, mirroring + * {@link matchAiChatShortcut}). Distinct from the AI-chat page shortcuts (O / S). + * Pure + exported for tests. + */ +export function matchChatDockShortcut(e: { + key: string; + metaKey: boolean; + ctrlKey: boolean; + shiftKey: boolean; + altKey: boolean; +}): ChatDockShortcut | null { + if (!(e.metaKey || e.ctrlKey) || !e.shiftKey || e.altKey) return null; + return e.key.toLowerCase() === 'i' ? 'toggle' : null; +} diff --git a/packages/app-shell/src/runtime-config.test.ts b/packages/app-shell/src/runtime-config.test.ts index 1116ea0cd2..28c1ad5ebd 100644 --- a/packages/app-shell/src/runtime-config.test.ts +++ b/packages/app-shell/src/runtime-config.test.ts @@ -52,6 +52,17 @@ describe('runtime-config commercial features', () => { // sanity: existing flags still parse expect(getRuntimeConfig().features.aiStudio).toBe(true); }); + + it('ADR-0057 P3a chatDock defaults OFF and is opt-in (rollout flag)', async () => { + expect(getRuntimeConfig().features.chatDock).toBeFalsy(); + mockConfig({ chatDock: true }); + await initRuntimeConfig(); + expect(getRuntimeConfig().features.chatDock).toBe(true); + resetRuntimeConfigForTesting(); + mockConfig({ aiStudio: true }); // older runtime, no chatDock key + await initRuntimeConfig(); + expect(getRuntimeConfig().features.chatDock).toBeFalsy(); + }); }); /** diff --git a/packages/app-shell/src/runtime-config.ts b/packages/app-shell/src/runtime-config.ts index 0140cab247..0711f129dc 100644 --- a/packages/app-shell/src/runtime-config.ts +++ b/packages/app-shell/src/runtime-config.ts @@ -54,6 +54,14 @@ export interface RuntimeFeatures { * (treated as off). Server-derived from the plan entitlements. */ sso?: boolean; + /** + * ADR-0057 P3a — render the console AI chat as a right-docked, collapsible + * rail (the VS Code / Cursor idiom) in addition to the floating FAB. Rollout + * flag, DEFAULT OFF: the dock is additive and changes nothing until an + * operator opts in (`OS_AI_CHAT_DOCK=1` / RuntimeConfigPlugin). The FAB stays + * the canonical entry until P3b retires it into the dock's launcher. + */ + chatDock?: boolean; } /** @@ -171,6 +179,8 @@ export async function initRuntimeConfig(baseUrl: string = ''): Promise { // them — never show a paid surface on an unknown/older runtime. customDomain: body.features.customDomain === true, sso: body.features.sso === true, + // ADR-0057 P3a rollout flag — default OFF (additive dock). + chatDock: body.features.chatDock === true, } : current.features, branding: body.branding diff --git a/packages/layout/src/AppShell.tsx b/packages/layout/src/AppShell.tsx index f875d33efb..9595212cab 100644 --- a/packages/layout/src/AppShell.tsx +++ b/packages/layout/src/AppShell.tsx @@ -31,6 +31,13 @@ export interface AppShellProps { defaultOpen?: boolean; /** App branding — applies CSS custom properties for theming */ branding?: AppShellBranding; + /** + * Optional right-side rail rendered as a flex sibling of the main content, so + * it reflows the content beside it (VS Code / Cursor idiom) rather than + * overlaying it (ADR-0057 P3a ChatDock). Absent → unchanged single-pane layout; + * this is purely additive. + */ + rightRail?: React.ReactNode; } /** @@ -231,6 +238,7 @@ export function AppShell({ className, defaultOpen = true, branding, + rightRail, }: AppShellProps) { // Apply branding CSS custom properties useAppShellBranding(branding, branding?.title); @@ -242,7 +250,7 @@ export function AppShell({ {navbar} - {/* 2. Lower section: sidebar + main content */} + {/* 2. Lower section: sidebar + main content (+ optional right rail) */}
{sidebar} @@ -250,6 +258,8 @@ export function AppShell({ {children} + {/* ADR-0057 P3a — additive right rail (ChatDock); absent → unchanged. */} + {rightRail}
);