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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/adr-0057-p3a-chatdock.md
Original file line numberDiff line numberDiff line change
@@ -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.
237 changes: 237 additions & 0 deletions packages/app-shell/src/layout/ChatDock.tsx
Original file line numberDiff line numberDiff line change
@@ -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<number>(() => 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<PendingFirstMessage | null>(null);

// OSS / no AI seat → the whole dock body is inert (the launcher is gated too).
if (!isLoading && agents.length === 0) return null;

return (
<ChatPane
key={`${chatApi ?? 'local'}:${conversationId ?? 'pending'}`}
agents={agents}
agentsLoading={isLoading}
agentsError={error}
activeAgent={activeAgent}
chatApi={chatApi}
apiBase={apiBase}
conversationId={conversationId}
initialMessages={initialMessages}
pendingFirstMessageRef={pendingFirstMessageRef}
onSent={() => {}}
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 (
<aside
data-testid="chat-dock-panel"
style={{ width: dock.width }}
className="relative hidden h-full shrink-0 flex-col border-l bg-background md:flex"
>
{/* Left-edge resize handle. */}
<div
role="separator"
aria-orientation="vertical"
aria-label={t('console.ai.dock.resize', { defaultValue: 'Resize chat' })}
onPointerDown={dock.onResizePointerDown}
className={cn(
'absolute left-0 top-0 z-10 h-full w-1 cursor-col-resize transition-colors hover:bg-primary/40',
dock.dragging && 'bg-primary/50',
)}
/>
<div className="flex h-11 shrink-0 items-center justify-between border-b px-3">
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-foreground/80">
<MessagesSquare className="size-3.5" />
{t('console.ai.dock.title', { defaultValue: 'Assistant' })}
</span>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={dock.collapse}
aria-label={t('console.ai.dock.collapse', { defaultValue: 'Collapse chat' })}
data-testid="chat-dock-collapse"
>
<PanelRightClose className="h-4 w-4" />
</Button>
</div>
<div className="min-h-0 flex-1">
<ChatDockConversation userId={userId} apiBase={apiBase} />
</div>
</aside>
);
}

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 (
<Button
variant="outline"
size="icon"
onClick={onExpand}
data-testid="chat-dock-launcher"
aria-label={t('console.ai.dock.open', { defaultValue: 'Open assistant' })}
title={t('console.ai.dock.open', { defaultValue: 'Open assistant (⌘⇧I)' })}
className="fixed right-0 top-1/2 z-40 hidden h-16 w-7 -translate-y-1/2 rounded-l-md rounded-r-none border-r-0 bg-background shadow-md md:inline-flex"
>
<MessagesSquare className="size-4" />
</Button>
);
}
30 changes: 29 additions & 1 deletion packages/app-shell/src/layout/ConsoleLayout.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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';
Expand All@@ -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 */
Expand DownExpand Up@@ -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.
Expand All@@ -107,6 +127,9 @@ export function ConsoleLayout({
/>
}
className="!p-0 overflow-y-auto overflow-x-hidden bg-muted/5"
rightRail={
dockEnabled && dock.expanded ? <ChatDockPanel dock={dock} userId={userId} /> : undefined
}
branding={
activeApp?.branding
? {
Expand DownExpand Up@@ -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 && <ChatDockLauncher onExpand={dock.expand} />}
</AppShell>
</MobileViewSwitcherProvider>
</CommandPaletteProvider>
Expand Down
50 changes: 50 additions & 0 deletions packages/app-shell/src/layout/__tests__/chatDockState.test.ts
Original file line numberDiff line numberDiff line change
@@ -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();
});
});
Loading
Loading