Skip to content

Commit 76eadf8

Browse files
os-zhuangclaude
andauthored
feat(console-ai): bind-on-create — build conversations re-key to app:X:build (ADR-0057 A1.b) (#2466)
* feat(console-ai): bind-on-create — unbound build conversation re-keys to app:X:build when it mints X (ADR-0057 A1.b) The magic flow (bare /ai/build) keys its conversation product-only, while Edit-with-AI keys app:X:build — so the thread that BUILT app X and the thread Edit-with-AI later opens for X were different conversations and the build history was stranded (Amendment A1 defect 3, reproduced live). - useChatConversation.rekeyScope(newScope): re-keys the CURRENT conversation (cache write + synchronous conversationScope flip) so the #2450 scope gate holds through the host's ?package= navigate — no pane remount mid-stream. - Legacy-scope fallback read (legacyScope/adoptLegacy): an app-scoped visit whose key was never cached adopts the product-only thread iff the host's predicate confirms the thread is bound to that app (pre-A1.b migration). Failing fetch degrades to a plain miss; the legacy key is never cleared (dock/FAB keep resolving through it). - AiChatPage: ChatPane reports the binding (post-URL-mirror only — single- navigator rule); the page re-keys and puts ?package= on the URL. Reload without ?package= self-heals from hydrated history. - A1.a chip is now a switcher: apps (by package) → their Edit-with-AI thread; New app → ?new=1 fresh unbound draft. Client-side only: conversation scope keying is a per-(user,scope) localStorage cache; the server needs no change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(adr-0057): mark A1.b shipped (#2466) with the two keying decisions Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(console-ai): defer bind-on-create to turn-idle; never wipe hydrated history on a same-conversation empty re-read Live verification caught a race the design review missed: re-keying the moment the binding draft lands MID-STREAM lets the scope-change re-resolve read the conversation before the server persists the turn (persistence is at turn COMPLETION), and a reconcile can then replace the live thread with that empty not-yet-persisted history — the pane blanked until reload. - ChatPane's bind effect now also waits for isLoading to drop: the re-key runs against a fully persisted turn (and 'bind at build completion' is truer to the named-on-first-save idiom anyway). Re-verified live: the thread stays visible through the bind, URL gains ?package=, and the Edit-with-AI entry resumes the same conversation with history. - useChatConversation's activeId branch no longer overwrites non-empty hydrated messages with an EMPTY read of the SAME conversation (a later remount would hydrate from that and render a blank thread); an empty read of a DIFFERENT conversation still replaces as before. Unit-tested. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 159d7db commit 76eadf8

4 files changed

Lines changed: 582 additions & 22 deletions

File tree

‎docs/adr/0057-console-ai-chat-one-conversation-docked.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,15 @@ degradation.
330330
- **A1.b — bind-on-create + re-key.** Conversation re-keys to `app:X:build`
331331
when the build mints package X (with a legacy-scope fallback read so
332332
existing product-scoped threads stay reachable); chip becomes a switcher.
333+
**Shipped (#2466)**: `useChatConversation.rekeyScope` (synchronous scope
334+
flip so the #2450 scope gate holds through the `?package=` navigate — no
335+
pane remount mid-stream) + a `legacyScope`/`adoptLegacy` migration read
336+
(adopt the product-only thread iff its own history is bound to X). Keying
337+
is client-side only (the per-`(user, scope)` localStorage cache; no server
338+
change). Two decisions made explicit: the legacy product-only key is **not
339+
cleared** (dock/FAB and bare `/ai/build` keep resolving through it), and
340+
re-keying is **latest-wins** (the bound thread most recently open becomes
341+
the app's Edit-with-AI thread; older ones stay in sidebar history).
333342
- **A1.c — cloud follow-up.** Inject the binding into the build agent's
334343
context block, and add the pending-blueprint rule: *a new authoring request
335344
while a blueprint awaits approval defaults to AMENDING that blueprint* (the

‎packages/app-shell/src/console/ai/AiChatPage.tsx‎

Lines changed: 173 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,15 @@ import {
4242
Empty,
4343
EmptyTitle,
4444
EmptyDescription,
45+
DropdownMenu,
46+
DropdownMenuTrigger,
47+
DropdownMenuContent,
48+
DropdownMenuItem,
49+
DropdownMenuLabel,
50+
DropdownMenuSeparator,
4551
cn,
4652
} from '@object-ui/components';
47-
import { Bug, PanelLeft, PanelLeftClose, PanelLeftOpen, PanelRightOpen, Share2 } from 'lucide-react';
53+
import { Bug, Check, ChevronDown, PanelLeft, PanelLeftClose, PanelLeftOpen, PanelRightOpen, Share2 } from 'lucide-react';
4854
import {
4955
ChatbotEnhanced,
5056
useAgents,
@@ -741,7 +747,29 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
741747
? chatConversationScope({ appId: editPackageId, product: chatProductOfAgent(activeAgent) })
742748
: undefined;
743749

744-
const { conversationId, conversationScope, initialMessages } = useChatConversation({
750+
// A1.b migration read: an "Edit with AI" entry (`?package=X`) whose
751+
// `app:X:build` scope has never been cached may still have its thread under
752+
// the product-only key — a build conversation from before bind-on-create
753+
// shipped (or whose re-key never ran in this browser). Offer that thread to
754+
// the resolve; adopt it only if its own history is actually bound to X
755+
// (latest draft/handoff package), so an unrelated product-scoped thread
756+
// never gets hijacked into an app scope.
757+
const activeProduct = activeAgent ? chatProductOfAgent(activeAgent) : undefined;
758+
const legacyBuildScope =
759+
editPackageId && activeProduct === 'build' && chatScope !== activeProduct
760+
? activeProduct
761+
: undefined;
762+
const adoptLegacyBuildThread = useCallback(
763+
(messages: HydratedUIMessage[]) =>
764+
Boolean(editPackageId) &&
765+
deriveBoundPackageId(
766+
hydratedMessagesToChatMessages(messages) as unknown as readonly PackageBearingMessage[],
767+
undefined,
768+
) === editPackageId,
769+
[editPackageId],
770+
);
771+
772+
const { conversationId, conversationScope, initialMessages, rekeyScope } = useChatConversation({
745773
// Gate resolution on the agent being known: resolving while `activeAgent`
746774
// is still undefined (catalog loading) would bind a SCOPELESS conversation
747775
// that the per-(user,scope) guard then sticks with — so the agent surface
@@ -752,6 +780,8 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
752780
apiBase,
753781
activeId: urlConversationId ?? legacyConversationId,
754782
forceNew: forceNewConversation,
783+
legacyScope: legacyBuildScope,
784+
adoptLegacy: legacyBuildScope ? adoptLegacyBuildThread : undefined,
755785
});
756786

757787
// #2450 — feed <ChatPane> ONLY a scope-matched conversation. Right after a
@@ -905,6 +935,45 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
905935
navigate(`/ai/${activeAgentRoute}/${conversationId}${pkgQuery}`, { replace: true });
906936
}, [segmentIsAgent, urlConversationId, conversationId, conversationScope, chatScope, activeAgentRoute, editPackageId, navigate]);
907937

938+
// ── A1.b bind-on-create ─────────────────────────────────────────────────
939+
// The pane's build just minted (or its hydrated history reveals) package
940+
// `pkg` while this conversation is keyed product-only: re-key it to
941+
// `app:${pkg}:build` so a later "Edit with AI" entry for that app resumes
942+
// THIS thread (the untitled-document idiom — named on first save), then put
943+
// `?package=` on the URL so the scope survives a reload. Order matters:
944+
// `rekeyScope` flips `conversationScope` synchronously BEFORE the navigate
945+
// re-render recomputes `chatScope`, so the #2450 scope gate stays matched
946+
// and the mounted pane (and any in-flight stream) is never remounted.
947+
// Only fires with the conversation id already in the URL (post-mirror) —
948+
// the single-navigator rule: the URL-mirror effect above is dormant once
949+
// `urlConversationId` is set, so the two can never fight over the URL from
950+
// stale closures. Idempotent under StrictMode double-effects.
951+
const handlePackageBound = useCallback(
952+
(pkg: string) => {
953+
if (!pkg || editPackageId || activeProduct !== 'build' || !activeAgentRoute) return;
954+
// Re-key only a settled, scope-matched, product-only conversation whose
955+
// id already reached the URL.
956+
if (chatScope !== activeProduct || conversationScope !== chatScope) return;
957+
if (!conversationId || urlConversationId !== conversationId) return;
958+
rekeyScope(chatConversationScope({ appId: pkg, product: activeProduct }));
959+
navigate(
960+
`/ai/${activeAgentRoute}/${encodeURIComponent(conversationId)}?package=${encodeURIComponent(pkg)}`,
961+
{ replace: true },
962+
);
963+
},
964+
[
965+
editPackageId,
966+
activeProduct,
967+
activeAgentRoute,
968+
chatScope,
969+
conversationScope,
970+
conversationId,
971+
urlConversationId,
972+
rekeyScope,
973+
navigate,
974+
],
975+
);
976+
908977
const titledRef = useRef<Set<string>>(new Set());
909978

910979
// A resumed conversation already has history; treat it as already-titled
@@ -1137,6 +1206,8 @@ export function AiChatPage({ apiBase: apiBaseProp, defaultAgent: defaultAgentPro
11371206
apiBase={apiBase}
11381207
conversationId={paneConversationId}
11391208
editPackageId={editPackageId}
1209+
onPackageBound={handlePackageBound}
1210+
canBind={Boolean(urlConversationId)}
11401211
parentHandoffConversationId={handoffParentConversationId}
11411212
initialMessages={paneInitialMessages}
11421213
pendingFirstMessageRef={pendingFirstMessageRef}
@@ -1216,6 +1287,15 @@ interface ChatPaneProps {
12161287
/** ADR-0070 "Edit with AI": the package the user opened to edit (from `?package=`),
12171288
* forwarded to the build agent as `context.packageId` to scope it to that app. */
12181289
editPackageId?: string;
1290+
/** A1.b bind-on-create: fired when this pane's build history binds to a package
1291+
* while the surface has no `?package=` — the page re-keys the conversation to
1292+
* `app:${pkg}:build` and writes `?package=` onto the URL. */
1293+
onPackageBound?: (pkg: string) => void;
1294+
/** A1.b — true once the conversation id is mirrored into the URL. Binding is
1295+
* deferred until then (single-navigator rule vs the page's URL-mirror effect);
1296+
* keeping it in the bind effect's deps re-fires the notification when the
1297+
* mirror lands, covering a hydrated bound thread opened via bare `/ai/build`. */
1298+
canBind?: boolean;
12191299
/** ADR-0057 P4 / cloud#817 — source `ask` conversation id (from `?parentConversationId=`),
12201300
* forwarded to the build agent's FIRST turn as `context.parentConversationId` so it
12211301
* starts with the ask thread as context. Undefined outside a handoff. */
@@ -1246,6 +1326,8 @@ export function ChatPane({
12461326
apiBase,
12471327
conversationId,
12481328
editPackageId,
1329+
onPackageBound,
1330+
canBind,
12491331
parentHandoffConversationId,
12501332
initialMessages,
12511333
pendingFirstMessageRef,
@@ -1545,6 +1627,38 @@ export function ChatPane({
15451627
return app ? appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }) : boundPackageId;
15461628
}, [boundPackageId, metadataApps, appLabel, t]);
15471629

1630+
// A1.b bind-on-create: report the binding to the page (which re-keys the
1631+
// conversation and puts `?package=` on the URL). Deferred behind `canBind`
1632+
// (the conversation id must be in the URL first — see ChatPaneProps) AND
1633+
// behind the turn being idle: the binding draft lands mid-stream, but
1634+
// re-keying then races the live stream (the scope-change refetch reads the
1635+
// conversation BEFORE the server persists the turn, and a stream hiccup's
1636+
// reconcile can then replace the live thread with that not-yet-persisted
1637+
// EMPTY history — the blanked-pane failure seen live). Waiting for
1638+
// `isLoading` to drop means the turn is fully persisted server-side, so
1639+
// every post-bind read returns the real history; the chip binding a moment
1640+
// after the build lands also matches the "named on first save" idiom. The
1641+
// deps re-fire the notification on each gate opening (mirror landed, stream
1642+
// ended, hydrated reload of a bound thread missing `?package=`).
1643+
useEffect(() => {
1644+
if (!isBuildSurface || !canBind || !boundPackageId || editPackageId || isLoading) return;
1645+
onPackageBound?.(boundPackageId);
1646+
}, [isBuildSurface, canBind, boundPackageId, editPackageId, isLoading, onPackageBound]);
1647+
1648+
// A1.b switcher menu: every published app with a package identity, deduped
1649+
// by package (apps sharing a package share the build thread — the scope is
1650+
// per-package). Selecting one navigates to its Edit-with-AI surface, which
1651+
// resumes (or mints) that app's own build conversation.
1652+
const switchablePackages = useMemo(() => {
1653+
const byPackage = new Map<string, string>();
1654+
for (const app of metadataApps ?? []) {
1655+
const pkg = (app as { _packageId?: string })._packageId;
1656+
if (!pkg || byPackage.has(pkg)) continue;
1657+
byPackage.set(pkg, appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }));
1658+
}
1659+
return Array.from(byPackage, ([id, label]) => ({ id, label }));
1660+
}, [metadataApps, appLabel, t]);
1661+
15481662
const headerSlot = (
15491663
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-border/50 px-4 pb-2 pt-3 sm:px-6">
15501664
<div className="flex min-w-0 flex-1 items-center gap-2">
@@ -1611,24 +1725,66 @@ export function ChatPane({
16111725
</span>
16121726
)}
16131727
{isBuildSurface ? (
1614-
boundPackageId ? (
1615-
<span
1728+
// A1.b — the binding chip is a SWITCHER now: each app gets its own
1729+
// build conversation (the scope is per-package), so picking one here
1730+
// switches threads via its Edit-with-AI entry; "New app" opens a
1731+
// fresh unbound draft (`?new=1` mints a separate thread).
1732+
<DropdownMenu>
1733+
<DropdownMenuTrigger
16161734
data-testid="ai-build-package-chip"
16171735
title={boundPackageId}
1618-
className="inline-flex min-w-0 items-center gap-1 rounded-md border bg-muted/40 px-2 py-0.5 text-xs text-foreground/80"
1619-
>
1620-
<PackageIcon className="size-3 shrink-0" />
1621-
<span className="max-w-[10rem] truncate">{boundPackageLabel}</span>
1622-
</span>
1623-
) : (
1624-
<span
1625-
data-testid="ai-build-package-chip"
1626-
className="inline-flex items-center gap-1 rounded-md border border-dashed px-2 py-0.5 text-xs text-muted-foreground"
1736+
aria-label={t('console.ai.switchApp', { defaultValue: 'Switch app' })}
1737+
className={cn(
1738+
'inline-flex min-w-0 items-center gap-1 rounded-md border px-2 py-0.5 text-xs hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-border/80',
1739+
boundPackageId
1740+
? 'bg-muted/40 text-foreground/80'
1741+
: 'border-dashed text-muted-foreground',
1742+
)}
16271743
>
1628-
<SparklesIcon className="size-3 shrink-0" />
1629-
{t('console.ai.newApp', { defaultValue: 'New app' })}
1630-
</span>
1631-
)
1744+
{boundPackageId ? (
1745+
<>
1746+
<PackageIcon className="size-3 shrink-0" />
1747+
<span className="max-w-[10rem] truncate">{boundPackageLabel}</span>
1748+
</>
1749+
) : (
1750+
<>
1751+
<SparklesIcon className="size-3 shrink-0" />
1752+
{t('console.ai.newApp', { defaultValue: 'New app' })}
1753+
</>
1754+
)}
1755+
<ChevronDown className="size-3 shrink-0 opacity-60" />
1756+
</DropdownMenuTrigger>
1757+
<DropdownMenuContent align="start" className="max-h-72 w-56 overflow-y-auto">
1758+
<DropdownMenuLabel className="text-xs text-muted-foreground">
1759+
{t('console.ai.switchAppLabel', { defaultValue: 'Build conversations by app' })}
1760+
</DropdownMenuLabel>
1761+
{switchablePackages.map((pkg) => (
1762+
<DropdownMenuItem
1763+
key={pkg.id}
1764+
data-testid={`ai-build-package-option-${pkg.id}`}
1765+
className="text-xs"
1766+
onSelect={() => {
1767+
if (pkg.id !== boundPackageId) {
1768+
navigate(`/ai/build?package=${encodeURIComponent(pkg.id)}`);
1769+
}
1770+
}}
1771+
>
1772+
<PackageIcon className="size-3 shrink-0" />
1773+
<span className="min-w-0 flex-1 truncate">{pkg.label}</span>
1774+
{pkg.id === boundPackageId ? <Check className="size-3 shrink-0" /> : null}
1775+
</DropdownMenuItem>
1776+
))}
1777+
{switchablePackages.length > 0 ? <DropdownMenuSeparator /> : null}
1778+
<DropdownMenuItem
1779+
data-testid="ai-build-package-option-new"
1780+
className="text-xs"
1781+
onSelect={() => navigate('/ai/build?new=1')}
1782+
>
1783+
<SparklesIcon className="size-3 shrink-0" />
1784+
{t('console.ai.newApp', { defaultValue: 'New app' })}
1785+
</DropdownMenuItem>
1786+
</DropdownMenuContent>
1787+
</DropdownMenu>
16321788
) : null}
16331789
</div>
16341790
<div className="flex shrink-0 items-center gap-1">

0 commit comments

Comments
 (0)