Skip to content
21 changes: 21 additions & 0 deletions .changeset/10926-dock-binds-build-thread-to-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@object-ui/app-shell': minor
---

The console's assistant dock binds its build conversation to the app you are in
(objectui#10926).

Inside an app whose package is not a `com.objectstack.*` platform built-in, a dock
whose agent resolves to `build` now keys its conversation `app:PKG:build` — the key
the Studio copilot and `/ai/build?package=PKG` already use — so the three show one
thread. The dock's header chip and empty state name the app instead of reading
"New app", and each build request carries the app's package as `context.packageId`,
so the agent no longer has to infer which app to edit. The dock's "Open full page"
button opens `/ai/build?package=PKG` for such a thread.

Unchanged: an `ask` dock keeps its app-less thread; inside a `com.objectstack.*`
app, or with no current app, a build dock keeps the product-only thread; and bare
`/ai/build` still resolves that product-only thread. The product-only thread is
never cleared — it stays listed in the `/ai` sidebar, and a dock entering an app
adopts it only when its own history is bound to that app (the same check the
`?package=` entry already applies).
27 changes: 22 additions & 5 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,27 @@ export function deriveBoundPackageId(
return latest;
}

/**
* ADR-0057 Amendment A1.b migration read — is a product-only (legacy-scope)
* build thread bound to `packageId` by its OWN history (the latest
* draft/handoff package)? The adopt predicate every `app:X:build` surface
* that runs the migration read hands `useChatConversation`: the full page's
* `?package=` entry and the console dock bound to the current app
* (objectui#10926). One predicate, so whichever surface resolves first adopts
* the same thread. Exported for that second caller.
*/
export function isThreadBoundToPackage(
messages: HydratedUIMessage[],
packageId: string,
): boolean {
return (
deriveBoundPackageId(
hydratedMessagesToChatMessages(messages) as unknown as readonly PackageBearingMessage[],
undefined,
) === packageId
);
}

/**
* #2466 / ADR-0057 Amendment A1.b — is this app a CODE-LOADED platform built-in
* (`com.objectstack.setup` / `account`, …) rather than a user- or AI-authored
Expand Down Expand Up @@ -953,11 +974,7 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
: undefined;
const adoptLegacyBuildThread = useCallback(
(messages: HydratedUIMessage[]) =>
Boolean(editPackageId) &&
deriveBoundPackageId(
hydratedMessagesToChatMessages(messages) as unknown as readonly PackageBearingMessage[],
undefined,
) === editPackageId,
editPackageId !== undefined && isThreadBoundToPackage(messages, editPackageId),
[editPackageId],
);

Expand Down
15 changes: 9 additions & 6 deletions packages/app-shell/src/hooks/chatScope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,16 @@
*
* ADR-0057 principle: **surface = view · conversation = model · product
* (`ask` / `build`) = the binding axis.** Conversations are keyed on
* **`(user, app, product)` — NOT on surface.** Two surfaces that resolve the
* **`(user, app, product)` — NOT on surface.** Surfaces that resolve the
* SAME `(appId, product)` therefore resume ONE shared thread instead of forking:
*
* - the full-page focus view `/ai/build?package=X` (the ADR-0070 "Edit with
* AI" deep-link — `editPackageId = X`), and
* - the Studio design copilot editing package `X` (`packageId = X`),
* AI" deep-link — `editPackageId = X`),
* - the Studio design copilot editing package `X` (`packageId = X`), and
* - the console dock inside app `X` when its agent is `build` and `X` is
* authorable (objectui#10926 — the dock's `editPackageId = X`),
*
* both produce `app:X:build`, so opening one after the other resumes the same
* all produce `app:X:build`, so opening one after another resumes the same
* design conversation rather than showing an empty copilot beside an active
* full-page thread (the forked-conversation bug this ADR fixes). The `(user)`
* dimension is applied by {@link useChatConversation}'s per-user cache key; this
Expand All @@ -24,8 +26,9 @@
* `ask`.
*
* When no app id is known — a generic `/ai/:agent` visit with no `?package=`,
* or the ambient console FAB — the key degrades to the product alone
* (`build` / `ask`), preserving today's per-product thread for that surface.
* or the console dock on `ask` or outside an authorable app — the key degrades
* to the product alone (`build` / `ask`), preserving the per-product thread for
* that surface.
*
* Kept as a pure, dependency-light module (only the agent-kind predicate from
* `@object-ui/plugin-chatbot`) so every shell resolves the key the SAME way and
Expand Down
123 changes: 102 additions & 21 deletions packages/app-shell/src/layout/ChatDock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,9 +29,15 @@ import {
import { Maximize2, 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.js';
import {
ChatPane,
isPlatformBuiltinApp,
isThreadBoundToPackage,
resolveApiBase,
type PendingFirstMessage,
} from '../console/ai/AiChatPage.js';
import { AiUsageIndicator } from './AiUsageIndicator.js';
import { useChatConversation } from '../hooks/index.js';
import { useChatConversation, type HydratedUIMessage } from '../hooks/index.js';
import { chatConversationScope, chatProductOfAgent } from '../hooks/chatScope.js';
import { resolveSurfaceAgent } from '../hooks/surfaceAgent.js';
import { useCanAuthorMetadata } from '../hooks/useCanAuthorMetadata.js';
Expand Down Expand Up @@ -238,22 +244,50 @@ interface ChatDockConversationProps {
* the FAB always did. Absent → the surface default (`ask`).
*/
defaultAgent?: string;
/**
* objectui#10926 — the owning package (`_packageId`) of the app the console
* is showing. Only a `build` thread binds to it, and only when it is
* authorable (see {@link ChatDockConversation}). Absent → app-less.
*/
appPackageId?: string;
/** ADR-0037/P3c — the Live Canvas open/close seam, forwarded to ChatPane. */
onCanvasOpenChange?: (open: boolean) => void;
/**
* objectui#10926 — reports the package the build thread is bound to
* (`undefined` = app-less), so the chrome's maximize opens that app's build
* surface instead of the bare `/ai`.
*/
onBoundPackageChange?: (packageId: string | undefined) => void;
}

const EMPTY_INITIAL_MESSAGES: HydratedUIMessage[] = [];

/**
* 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).
* The dock's chat body — resolves the surface agent + its P1 conversation and
* mounts the shared {@link ChatPane}. Mirrors StudioAiCopilot's minimal embed on
* the `default` surface. Renders nothing when the AI catalog is empty (OSS / no
* seat).
*
* The scope follows ADR-0057 as amended for objectui#10926 (maintainer ruling
* 「绑定当前应用(推荐)」): inside an AUTHORABLE app — its package is not a
* `com.objectstack.*` built-in, the A1.b switcher's own predicate — a `build`
* thread binds to that app. `editPackageId` is the app's package, the scope is
* `app:PKG:build`, the key the Studio copilot and `/ai/build?package=PKG`
* resolve, so the three show ONE thread; the chip, the empty state and the
* agent context (`context.packageId`) name the app. `ask` stays ambient, and
* outside an authorable app the key is the product alone, as before. The
* product-only thread is never cleared: it stays under its own key and in the
* `/ai` sidebar, and — through the same A1.b migration read the full page runs
* — it is adopted into the app's scope only when its own history is bound to
* that app.
*/
function ChatDockConversation({
userId,
apiBase,
defaultAgent,
appPackageId,
onCanvasOpenChange,
onBoundPackageChange,
}: ChatDockConversationProps) {
const { agents, isLoading, error } = useAgents({ apiBase });
// cloud#1674 maker convergence — the console dock is a maker surface, so an
Expand All @@ -273,32 +307,55 @@ function ChatDockConversation({
const chatApi = activeAgent
? `${apiBase}/agents/${encodeURIComponent(activeAgent)}/chat`
: undefined;
const product = chatProductOfAgent(activeAgent);
const editPackageId =
product === 'build' && appPackageId && !isPlatformBuiltinApp({ _packageId: appPackageId })
? appPackageId
: undefined;
const scope = activeAgent
? chatConversationScope({ appId: undefined, product: chatProductOfAgent(activeAgent) })
? chatConversationScope({ appId: editPackageId, product })
: undefined;
const { conversationId, initialMessages } = useChatConversation({
const adoptLegacyBuildThread = React.useCallback(
(messages: HydratedUIMessage[]) =>
editPackageId !== undefined && isThreadBoundToPackage(messages, editPackageId),
[editPackageId],
);
const { conversationId, conversationScope, initialMessages } = useChatConversation({
userId: activeAgent ? userId : undefined,
scope,
apiBase,
activeId: undefined,
forceNew: false,
legacyScope: editPackageId ? product : undefined,
adoptLegacy: editPackageId ? adoptLegacyBuildThread : undefined,
});
// #2450 — hand the pane only a scope-matched conversation. Moving between
// apps changes the scope while the dock stays mounted; until the hook
// re-resolves, `conversationId` is still the previous app's thread, and a
// pane mounted on it would pair that thread with the new app's package.
const scopeMatched = conversationScope === scope;
const paneConversationId = scopeMatched ? conversationId : undefined;
const paneInitialMessages = scopeMatched ? initialMessages : EMPTY_INITIAL_MESSAGES;
React.useEffect(() => {
onBoundPackageChange?.(editPackageId);
}, [editPackageId, onBoundPackageChange]);
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'}`}
key={`${chatApi ?? 'local'}:${paneConversationId ?? 'pending'}`}
agents={agents}
agentsLoading={isLoading}
agentsError={error}
activeAgent={activeAgent}
chatApi={chatApi}
apiBase={apiBase}
conversationId={conversationId}
initialMessages={initialMessages}
conversationId={paneConversationId}
editPackageId={editPackageId}
initialMessages={paneInitialMessages}
pendingFirstMessageRef={pendingFirstMessageRef}
onSent={() => {}}
onShare={() => {}}
Expand All @@ -315,18 +372,27 @@ export interface ChatDockPanelProps {
apiBase?: string;
/** `app.defaultAgent` for the default body's resolver. Unused with `children`. */
defaultAgent?: string;
/**
* objectui#10926 — the current app's owning package, for the default body's
* build-thread binding (see {@link ChatDockConversation}). Unused with
* `children`.
*/
appPackageId?: string;
/** Header title override (the Studio dock says "AI copilot"); default "Assistant". */
title?: string;
/**
* ADR-0057 P3c — render a maximize header button that opens the full-page
* focus surface (`/ai…`) on the SAME thread. The caller supplies the
* navigation because the right target is per-surface (console → `/ai`,
* Studio → `/ai/build?package=…`).
* Studio → `/ai/build?package=…`). The default body's bound package
* (objectui#10926) is passed along — `undefined` when the thread is
* app-less, and always with a `children` body.
*/
onMaximize?: () => void;
onMaximize?: (boundPackageId?: string) => void;
/**
* Body override. Default mounts {@link ChatDockConversation} (the console's
* ambient ask thread). The Studio dock passes its own package-scoped build
* own thread — app-bound for `build` inside an authorable app, product-only
* otherwise). The Studio dock passes its own package-scoped build
* conversation instead — note the empty-catalog gate then lives in the
* caller, because the default body's self-gate is bypassed.
*/
Expand All @@ -343,12 +409,14 @@ export function ChatDockPanel({
userId,
apiBase: apiBaseProp,
defaultAgent,
appPackageId,
title,
onMaximize,
children,
}: ChatDockPanelProps) {
const { t } = useObjectTranslation();
const apiBase = React.useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
const [boundPackageId, setBoundPackageId] = React.useState<string | undefined>(undefined);
// ADR-0037/P3c — the default body auto-maximizes the rail while the Live
// Canvas is open and tucks back when it closes (restore self-guards against
// the mount-time false, StrictMode, and user-drag takeover).
Expand Down Expand Up @@ -394,7 +462,7 @@ export function ChatDockPanel({
variant="ghost"
size="icon"
className="h-7 w-7 text-muted-foreground hover:text-foreground"
onClick={onMaximize}
onClick={() => onMaximize(boundPackageId)}
aria-label={t('console.ai.dock.maximize', { defaultValue: 'Open full page' })}
title={t('console.ai.dock.maximize', { defaultValue: 'Open full page' })}
data-testid="chat-dock-maximize"
Expand Down Expand Up @@ -426,7 +494,9 @@ export function ChatDockPanel({
userId={userId}
apiBase={apiBase}
defaultAgent={defaultAgent}
appPackageId={appPackageId}
onCanvasOpenChange={handleCanvasOpenChange}
onBoundPackageChange={setBoundPackageId}
/>
)}
</div>
Expand All @@ -443,14 +513,17 @@ export interface ChatDockMobileSheetProps {
apiBase?: string;
/** `app.defaultAgent` for the default body's resolver. Unused with `children`. */
defaultAgent?: string;
/** objectui#10926 — same contract as {@link ChatDockPanelProps.appPackageId}. */
appPackageId?: string;
/** Header title override; default "Assistant". */
title?: string;
/**
* Open the full-page `/ai` surface (which on mobile already carries the
* conversation-history sidebar + share). Runs DEFERRED — see the doc below
* for why the sheet must close before this navigates.
* for why the sheet must close before this navigates. Receives the default
* body's bound package, as {@link ChatDockPanelProps.onMaximize} does.
*/
onMaximize?: () => void;
onMaximize?: (boundPackageId?: string) => void;
/** Body override — same contract as {@link ChatDockPanel}. */
children?: React.ReactNode;
}
Expand Down Expand Up @@ -480,20 +553,22 @@ export function ChatDockMobileSheet({
userId,
apiBase: apiBaseProp,
defaultAgent,
appPackageId,
title,
onMaximize,
children,
}: ChatDockMobileSheetProps) {
const { t } = useObjectTranslation();
const apiBase = React.useMemo(() => resolveApiBase(apiBaseProp), [apiBaseProp]);
const [boundPackageId, setBoundPackageId] = React.useState<string | undefined>(undefined);
// Armed by the maximize click; fired once the sheet has actually closed.
const pendingMaximizeRef = React.useRef(false);
React.useEffect(() => {
if (!open && pendingMaximizeRef.current) {
pendingMaximizeRef.current = false;
onMaximize?.();
onMaximize?.(boundPackageId);
}
}, [open, onMaximize]);
}, [open, onMaximize, boundPackageId]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
Expand Down Expand Up @@ -537,7 +612,13 @@ export function ChatDockMobileSheet({
{/* flex-col — same bounded-height reason as the desktop dock body above. */}
<div className="flex min-h-0 flex-1 flex-col">
{children ?? (
<ChatDockConversation userId={userId} apiBase={apiBase} defaultAgent={defaultAgent} />
<ChatDockConversation
userId={userId}
apiBase={apiBase}
defaultAgent={defaultAgent}
appPackageId={appPackageId}
onBoundPackageChange={setBoundPackageId}
/>
)}
</div>
</SheetContent>
Expand Down
Loading
Loading