diff --git a/.changeset/11666-launcher-plan-awaiting-approval.md b/.changeset/11666-launcher-plan-awaiting-approval.md new file mode 100644 index 0000000000..ae5f1f38db --- /dev/null +++ b/.changeset/11666-launcher-plan-awaiting-approval.md @@ -0,0 +1,18 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/plugin-chatbot': minor +'@object-ui/i18n': minor +--- + +The chat launchers show a marker while a proposed plan awaits the user's approval (objectui#11666, item 6 of objectui#2458). A user who closed the chat with a blueprint still waiting on them used to get no sign of it from the launcher they return to. + +What a user sees: while the newest proposed plan of a conversation still offers "Build it", the console's assistant button (the floating launcher on app pages and Home) and the ChatDock edge launcher Studio uses carry a small amber dot. Assistive tech reads it as the button's description, from the new `console.ai.dock.planAwaitingApproval` key in the active language. With no plan awaiting, neither launcher shows anything. The dot goes away when the plan stops awaiting: the user approves it, its build runs, or a newer proposal takes its place and is itself approved or built. Opening the chat clears nothing by itself, and neither does opening another conversation. Deleting the conversation from the `/ai` sidebar drops it. + +Where the reading comes from: the chat's own plan card. `ChatbotEnhanced` derives "awaiting approval" from the same producer its card header and body read (`resolveProposalCardState` reads `pending`), and reports it to its host. The console's chat pane publishes that reading on the assistant bus, per conversation and per signed-in user, and the launchers read the bus. They import no chat code. The reading is exact for everything that happens in the tab. It does not see a decision made in another tab or on another device, and a page reload starts it empty until a chat on that conversation is opened again. The durable copy is the server conversation, which the launchers do not read. + +**Clause-②: yes (widening).** Two published surfaces widen: + +- `@object-ui/plugin-chatbot`: the exported `ChatbotEnhancedProps` type gains one optional member, `onPlanApprovalPendingChange`, a callback that takes one boolean and returns nothing. `ChatbotEnhanced` calls it once on mount and again whenever the boolean changes. Without it nothing changes. +- `@object-ui/i18n`: every built-in locale pack gains one key, `console.ai.dock.planAwaitingApproval`. The `en` pack is exported from the package entry, so the key also joins the translation-key type derived from it. + +`@object-ui/app-shell` adds no export: the bus functions the pane and the launchers use (`publishPlanApprovalPending`, `usePlanApprovalPending`) ship inside `dist/` but are not on the package entry, and the exported `assistantBus` object and `AssistantSnapshot` type are unchanged. No prop, export or key is removed, and no existing member changes type. diff --git a/packages/app-shell/src/assistant/assistantBus.ts b/packages/app-shell/src/assistant/assistantBus.ts index c29877da22..a12cdd0ce4 100644 --- a/packages/app-shell/src/assistant/assistantBus.ts +++ b/packages/app-shell/src/assistant/assistantBus.ts @@ -21,6 +21,7 @@ */ import { useEffect, useSyncExternalStore } from 'react'; +import { useAuth } from '@object-ui/auth'; export interface AssistantEditorField { name: string; @@ -167,6 +168,83 @@ export function subscribeMetadataRefresh(listener: () => void): () => void { }; } +// ── objectui#11666 — a proposed plan awaiting the user's approval ────────── +// The chat knows, per conversation, whether the newest proposed plan still +// waits for the user: `ChatbotEnhanced` derives it from the same producer its +// plan card renders and reports it through `onPlanApprovalPendingChange`, and +// the chat host (`ChatPane`) publishes that reading here. The launchers — the +// console FAB and the ChatDock edge launcher — read it, and they are on screen +// only while the chat is CLOSED, when no chat code is mounted to ask. This +// store is where the reading outlives the chat. +// +// Only a mounted chat writes it, and every change to a plan's state in this +// document happens inside a mounted chat (the approval click, a newer +// proposal, the build that runs), so within the document it is the chat's own +// reading, not a guess. What it cannot see is stated rather than papered over: +// a change made in another tab or device, and a page reload. The durable copy +// is the server conversation, which the launcher cannot read without the chat +// graph it exists to keep out of the first load. +// +// Two keys, both load-bearing: +// * the conversation — opening a chat on ANOTHER thread does not clear a +// plan waiting in this one, so opening a chat clears nothing by itself; +// * the owner — the SPA keeps running across a sign-out (`AuthProvider`), so +// a reading is shown only to the user it was read for, never carried into +// the next session in the tab. +// Kept OFF the snapshot bus above, like the two event channels: a plan's state +// must not re-render every `useAssistant` consumer. + +/** conversation id → the user whose chat read its plan as awaiting approval. */ +let planApprovalOwners: ReadonlyMap = new Map(); +const planApprovalListeners = new Set<() => void>(); + +function subscribePlanApproval(listener: () => void): () => void { + planApprovalListeners.add(listener); + return () => { + planApprovalListeners.delete(listener); + }; +} + +function getPlanApprovalOwners(): ReadonlyMap { + return planApprovalOwners; +} + +/** + * Record a chat's reading of whether `conversationId`'s newest proposed plan + * awaits the user's approval (chat hosts call this). No-op when unchanged. + * `pending: false` also drops the reading of a conversation that was deleted. + */ +export function publishPlanApprovalPending(reading: { + userId: string | undefined; + conversationId: string; + pending: boolean; +}): void { + const owner = reading.userId ?? ''; + const current = planApprovalOwners.get(reading.conversationId); + if (reading.pending ? current === owner : current === undefined) return; + const next = new Map(planApprovalOwners); + if (reading.pending) next.set(reading.conversationId, owner); + else next.delete(reading.conversationId); + planApprovalOwners = next; + for (const l of planApprovalListeners) l(); +} + +/** + * True while any conversation of the signed-in user has a proposed plan + * awaiting approval, as last read by a mounted chat. Read by the launchers. + */ +export function usePlanApprovalPending(): boolean { + const owners = useSyncExternalStore( + subscribePlanApproval, + getPlanApprovalOwners, + getPlanApprovalOwners, + ); + const { user } = useAuth(); + const owner = user?.id ?? ''; + for (const o of owners.values()) if (o === owner) return true; + return false; +} + /** Subscribe a component to the assistant bus snapshot. */ export function useAssistant(): AssistantSnapshot { return useSyncExternalStore( diff --git a/packages/app-shell/src/console/ai/AiChatPage.tsx b/packages/app-shell/src/console/ai/AiChatPage.tsx index e568849c1c..e6e123ec1e 100644 --- a/packages/app-shell/src/console/ai/AiChatPage.tsx +++ b/packages/app-shell/src/console/ai/AiChatPage.tsx @@ -107,7 +107,7 @@ import { import { AppHeader } from '../../layout/AppHeader.js'; import { armChatDockExpanded, readDockReturnLocation } from '../../layout/chatDockState.js'; import { fetchPendingDraftCount } from '../../preview/draftStatus.js'; -import { emitMetadataRefresh } from '../../assistant/assistantBus.js'; +import { emitMetadataRefresh, publishPlanApprovalPending } from '../../assistant/assistantBus.js'; import { getRuntimeConfig, isAiStudioEnabled } from '../../runtime-config.js'; import { makerConvergedOnBuild, makerVisibleAgents } from '../../hooks/surfaceAgent.js'; import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js'; @@ -2135,6 +2135,26 @@ export function ChatPane({ emitMetadataRefresh(); }, [isLoading, messages]); + // objectui#11666 — the launchers (console FAB, ChatDock edge launcher) show + // a marker while a proposed plan awaits the user's approval, and they are on + // screen only while this pane is NOT mounted. So the chat's own reading — + // `ChatbotEnhanced` reports it from the producer its plan card renders — is + // published on the assistant bus, per conversation and per user, and outlives + // this pane's unmount. `undefined` until the chat has reported once, so a + // mount never announces a reading it has not taken. Nothing here clears it on + // open: an open re-reads the same thread and reports the same state. + const { user: planApprovalUser } = useAuth(); + const planApprovalUserId = planApprovalUser?.id; + const [planApprovalPending, setPlanApprovalPending] = useState(undefined); + useEffect(() => { + if (!conversationId || planApprovalPending === undefined) return; + publishPlanApprovalPending({ + userId: planApprovalUserId, + conversationId, + pending: planApprovalPending, + }); + }, [planApprovalUserId, conversationId, planApprovalPending]); + // A1.b switcher menu: every published app with a package identity, deduped // by package (apps sharing a package share the build thread — the scope is // per-package). Selecting one navigates to its Edit-with-AI surface, which @@ -2643,6 +2663,8 @@ export function ChatPane({ // ADR-0045: build materialized → canvas leaves the draft overlay for // the real (unlisted) app; the reload shows live seed rows. onBuildMaterialized={handleBuildMaterialized} + // objectui#11666 — mirrored onto the assistant bus (effect above). + onPlanApprovalPendingChange={setPlanApprovalPending} previewDraftLabel={t('console.ai.previewDraft', { defaultValue: 'Preview' })} data-testid="ai-chat-panel" /> diff --git a/packages/app-shell/src/console/ai/__tests__/AiChatPage.planApprovalReachesLaunchers-11666.test.tsx b/packages/app-shell/src/console/ai/__tests__/AiChatPage.planApprovalReachesLaunchers-11666.test.tsx new file mode 100644 index 0000000000..cb9199e555 --- /dev/null +++ b/packages/app-shell/src/console/ai/__tests__/AiChatPage.planApprovalReachesLaunchers-11666.test.tsx @@ -0,0 +1,178 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#11666 — a proposed plan awaiting approval reaches the launcher the + * user returns to, and survives the chat closing. + * + * The launchers are on screen only while the chat is closed, so the reading + * must leave the chat: `ChatbotEnhanced` reports it (pinned in plugin-chatbot's + * `ChatbotEnhanced.planApprovalPending-11666.test.tsx`), `ChatPane` publishes it + * on the assistant bus, and the FAB reads the bus. This file pins the middle + * hop end to end — the REAL `ChatPane` and the REAL FAB, with `ChatbotEnhanced` + * replaced by a prop recorder so the case can play the chat's reading. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, renderHook, screen } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import React from 'react'; + +/** Props of the last `ChatbotEnhanced` render. */ +let captured: Record = {}; + +vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => { + const actual = await importOriginal>(); + return { + ...actual, + ChatbotEnhanced: (props: Record) => { + captured = props; + return null; + }, + useObjectChat: () => ({ + messages: [], + isLoading: false, + error: undefined, + sendMessage: vi.fn(), + stop: vi.fn(), + reload: vi.fn(), + clear: vi.fn(), + setMessages: vi.fn(), + }), + useAiModels: () => ({ models: [], defaultModelId: undefined }), + }; +}); + +vi.mock('../../../providers/MetadataProvider', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useMetadata: () => ({ apps: [] }) }; +}); +vi.mock('../../../providers/AdapterProvider', async (importOriginal) => { + const actual = await importOriginal>(); + return { ...actual, useAdapter: () => null }; +}); + +import { I18nProvider } from '@object-ui/i18n'; +import type { AgentDescriptor } from '@object-ui/plugin-chatbot'; +import { ChatPane } from '../AiChatPage'; +import { ConsoleChatbotFab } from '../../../layout/ConsoleChatbotFab'; +import { useConversationList } from '../../../hooks/useConversationList'; +import { publishPlanApprovalPending } from '../../../assistant/assistantBus'; + +window.matchMedia = ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: () => {}, + removeEventListener: () => {}, + addListener: () => {}, + removeListener: () => {}, + dispatchEvent: () => false, +})) as unknown as typeof window.matchMedia; + +const MARKER = 'console-chatbot-fab-plan-pending'; + +function renderPane(conversationId: string) { + captured = {}; + return render( + + + + + , + ); +} + +function renderFab() { + return render( + + {}} /> + , + ); +} + +/** Play the chat's own reading, as `ChatbotEnhanced` reports it. */ +function chatReports(pending: boolean) { + const report = captured.onPlanApprovalPendingChange as (p: boolean) => void; + expect(typeof report).toBe('function'); + act(() => report(pending)); +} + +afterEach(() => { + for (const id of ['conv-1', 'conv-2']) { + act(() => publishPlanApprovalPending({ userId: undefined, conversationId: id, pending: false })); + } + cleanup(); + window.localStorage.clear(); + vi.unstubAllGlobals(); +}); + +describe('ChatPane → assistant bus → FAB (objectui#11666)', () => { + it('the chat reading a waiting plan lights the FAB, and the marker outlives the chat closing', () => { + renderFab(); + const pane = renderPane('conv-1'); + expect(screen.queryByTestId(MARKER)).not.toBeInTheDocument(); + + chatReports(true); + expect(screen.getByTestId(MARKER)).toBeInTheDocument(); + + // The chat closes (the dock collapses, the pane unmounts): nothing clears. + pane.unmount(); + expect(screen.getByTestId(MARKER)).toBeInTheDocument(); + }); + + it('reopening the chat clears nothing by itself; the plan leaving "awaiting" does', () => { + renderFab(); + const first = renderPane('conv-1'); + chatReports(true); + first.unmount(); + + // Reopen on the same thread: mounting announces nothing until the chat reads. + renderPane('conv-1'); + expect(screen.getByTestId(MARKER)).toBeInTheDocument(); + chatReports(true); + expect(screen.getByTestId(MARKER)).toBeInTheDocument(); + + // The user approves (or the build runs): the chat reads false. + chatReports(false); + expect(screen.queryByTestId(MARKER)).not.toBeInTheDocument(); + }); + + it('a chat on another thread reporting no plan does not clear the waiting one', () => { + renderFab(); + const first = renderPane('conv-1'); + chatReports(true); + first.unmount(); + + renderPane('conv-2'); + chatReports(false); + expect(screen.getByTestId(MARKER)).toBeInTheDocument(); + }); + + it('deleting the thread drops its reading (the /ai sidebar delete)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(null, { status: 204 }))); + renderFab(); + const pane = renderPane('conv-1'); + chatReports(true); + pane.unmount(); + expect(screen.getByTestId(MARKER)).toBeInTheDocument(); + + const { result } = renderHook(() => useConversationList({ userId: undefined, apiBase: '/api/v1/ai' })); + await act(async () => { + await result.current.remove('conv-1'); + }); + expect(screen.queryByTestId(MARKER)).not.toBeInTheDocument(); + }); +}); diff --git a/packages/app-shell/src/hooks/useConversationList.ts b/packages/app-shell/src/hooks/useConversationList.ts index 1a6d158812..8896dc2fef 100644 --- a/packages/app-shell/src/hooks/useConversationList.ts +++ b/packages/app-shell/src/hooks/useConversationList.ts @@ -9,6 +9,7 @@ */ import { useCallback, useEffect, useState } from 'react'; +import { publishPlanApprovalPending } from '../assistant/assistantBus.js'; /** * One row of the conversation-history list. @@ -167,9 +168,12 @@ export function useConversationList( }); } finally { setConversations((rows) => rows.filter((r) => r.id !== id)); + // objectui#11666 — a deleted thread has no plan left to approve, so + // its launcher-marker reading goes with the row. + publishPlanApprovalPending({ userId, conversationId: id, pending: false }); } }, - [apiBase], + [apiBase, userId], ); const rename = useCallback( diff --git a/packages/app-shell/src/layout/ChatDock.tsx b/packages/app-shell/src/layout/ChatDock.tsx index 948eb41769..411d96b7d4 100644 --- a/packages/app-shell/src/layout/ChatDock.tsx +++ b/packages/app-shell/src/layout/ChatDock.tsx @@ -37,6 +37,7 @@ import { type PendingFirstMessage, } from '../console/ai/AiChatPage.js'; import { AiUsageIndicator } from './AiUsageIndicator.js'; +import { usePlanApprovalPending } from '../assistant/assistantBus.js'; import { useChatConversation, type HydratedUIMessage } from '../hooks/index.js'; import { chatConversationScope, chatProductOfAgent } from '../hooks/chatScope.js'; import { resolveSurfaceAgent } from '../hooks/surfaceAgent.js'; @@ -638,9 +639,15 @@ export interface ChatDockLauncherProps { * only while collapsed, so it never overlaps the expanded rail. The console * retired it in P3b (the FAB is that surface's launcher); the Studio dock (P3c) * uses it as its collapsed state, since Studio has no FAB. + * + * objectui#11666 — like the console FAB, it carries a marker while a proposed + * plan awaits the user's approval (read off the assistant bus, which the chat + * publishes to). Expanding the dock does not clear it; the plan's state does. */ export function ChatDockLauncher({ onExpand, className }: ChatDockLauncherProps) { const { t } = useObjectTranslation(); + const planPending = usePlanApprovalPending(); + const markerId = React.useId(); return ( ); } diff --git a/packages/app-shell/src/layout/ConsoleChatbotFab.tsx b/packages/app-shell/src/layout/ConsoleChatbotFab.tsx index 93b55a7562..a339e9fd82 100644 --- a/packages/app-shell/src/layout/ConsoleChatbotFab.tsx +++ b/packages/app-shell/src/layout/ConsoleChatbotFab.tsx @@ -12,10 +12,16 @@ * path (and the flag) entirely — the dock loads the chat graph on demand, so * the FAB stays dependency-free. * + * objectui#11666 — while a proposed plan awaits the user's approval, the FAB + * carries a small marker, so a user who closed the chat still sees that the + * assistant is waiting on them. The reading comes off the assistant bus (the + * chat publishes it; see `usePlanApprovalPending`), never from chat code here, + * and only a change of the plan's state clears it — opening the chat does not. + * * @module */ -import React, { useEffect, useRef } from 'react'; -import { useAssistant } from '../assistant/assistantBus.js'; +import React, { useEffect, useId, useRef } from 'react'; +import { useAssistant, usePlanApprovalPending } from '../assistant/assistantBus.js'; import { useObjectTranslation } from '@object-ui/i18n'; export interface ConsoleChatbotFabProps { @@ -39,10 +45,14 @@ export function ConsoleChatbotFab({ appLabel, onOpenDock }: ConsoleChatbotFabPro } }, [openSeq, onOpenDock]); + const planPending = usePlanApprovalPending(); + const markerId = useId(); + return ( ); } diff --git a/packages/app-shell/src/layout/__tests__/launcherPlanApprovalMarker-11666.test.tsx b/packages/app-shell/src/layout/__tests__/launcherPlanApprovalMarker-11666.test.tsx new file mode 100644 index 0000000000..1c898ee7ff --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/launcherPlanApprovalMarker-11666.test.tsx @@ -0,0 +1,135 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * objectui#11666 — both chat launchers (the console FAB and the ChatDock edge + * launcher Studio uses) carry a marker while a proposed plan awaits the user's + * approval, with a translated accessible name, and nothing otherwise. + * + * The launchers read the assistant bus, which a mounted chat publishes to + * (`publishPlanApprovalPending`; the ChatPane → bus → FAB path is pinned in + * `AiChatPage.planApprovalReachesLaunchers-11666.test.tsx`). The marker text is + * exposed as the button's accessible DESCRIPTION: a button's children are + * presentational, so a name on the dot itself would reach no assistive tech. + */ +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { act, cleanup, render, screen } from '@testing-library/react'; +import React from 'react'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { ConsoleChatbotFab } from '../ConsoleChatbotFab'; +import { ChatDockLauncher } from '../ChatDock'; +import { publishPlanApprovalPending } from '../../assistant/assistantBus'; + +// The ChatDock module's default body drags in the whole chat graph — irrelevant +// to the launcher, which is all this file renders from it. +vi.mock('../../console/ai/AiChatPage', () => ({ + ChatPane: () => null, + resolveApiBase: (explicit?: string) => explicit ?? '/api/v1/ai', +})); + +const EN_MARKER = 'A proposed plan is waiting for your approval'; + +// No AuthProvider here: `useAuth` answers its signed-out default, whose user id +// the bus reads as '' — the owner every reading below is published for. +const ME = undefined; + +const LAUNCHERS = [ + { + name: 'console FAB', + testId: 'console-chatbot-fab', + markerTestId: 'console-chatbot-fab-plan-pending', + element: {}} />, + }, + { + name: 'ChatDock launcher', + testId: 'chat-dock-launcher', + markerTestId: 'chat-dock-launcher-plan-pending', + element: {}} />, + }, +] as const; + +function renderIn(locale: string, element: React.ReactElement) { + return render( + {element}, + ); +} + +function publish(conversationId: string, pending: boolean, userId: string | undefined = ME) { + act(() => publishPlanApprovalPending({ userId, conversationId, pending })); +} + +afterEach(() => { + // The bus is a module singleton: drop every reading a case left behind. + for (const id of ['conv-a', 'conv-b']) { + for (const userId of [ME, 'someone-else']) { + act(() => publishPlanApprovalPending({ userId, conversationId: id, pending: false })); + } + } + cleanup(); + window.localStorage.clear(); +}); + +describe.each(LAUNCHERS)('$name — the plan-awaiting-approval marker (objectui#11666)', (launcher) => { + it('shows nothing while no plan awaits approval', () => { + renderIn('en', launcher.element); + expect(screen.queryByTestId(launcher.markerTestId)).not.toBeInTheDocument(); + expect(screen.getByTestId(launcher.testId)).not.toHaveAttribute('aria-describedby'); + }); + + it('shows the marker, with its translated accessible name, while one does', () => { + renderIn('en', launcher.element); + publish('conv-a', true); + + expect(screen.getByTestId(launcher.markerTestId)).toBeInTheDocument(); + expect(screen.getByTestId(launcher.testId)).toHaveAccessibleDescription(EN_MARKER); + }); + + it('names the marker in the UI locale, not in hard-coded English', async () => { + renderIn('zh', launcher.element); + publish('conv-a', true); + + const zh = (builtInLocales.zh as unknown as { console: { ai: { dock: { planAwaitingApproval: string } } } }) + .console.ai.dock.planAwaitingApproval; + expect(zh).not.toBe(EN_MARKER); + await vi.waitFor(() => + expect(screen.getByTestId(launcher.testId)).toHaveAccessibleDescription(zh), + ); + }); + + it('clears when the plan stops awaiting (approved, built or superseded), not before', () => { + renderIn('en', launcher.element); + publish('conv-a', true); + expect(screen.getByTestId(launcher.markerTestId)).toBeInTheDocument(); + + publish('conv-a', false); + expect(screen.queryByTestId(launcher.markerTestId)).not.toBeInTheDocument(); + expect(screen.getByTestId(launcher.testId)).not.toHaveAttribute('aria-describedby'); + }); + + it('a chat opened on ANOTHER thread clears nothing — the waiting plan still shows', () => { + renderIn('en', launcher.element); + publish('conv-a', true); + publish('conv-b', false); + expect(screen.getByTestId(launcher.markerTestId)).toBeInTheDocument(); + }); + + it("never shows another user's reading (the SPA outlives a sign-out)", () => { + renderIn('en', launcher.element); + publish('conv-a', true, 'someone-else'); + expect(screen.queryByTestId(launcher.markerTestId)).not.toBeInTheDocument(); + }); +}); + +describe('every locale pack names the marker (objectui#11666)', () => { + it.each(Object.keys(builtInLocales))('%s', (code) => { + const pack = builtInLocales[code as keyof typeof builtInLocales] as unknown as { + console: { ai: { dock: { planAwaitingApproval?: unknown } } }; + }; + const text = pack.console.ai.dock.planAwaitingApproval; + expect(typeof text).toBe('string'); + expect((text as string).trim()).not.toBe(''); + if (code !== 'en') expect(text).not.toBe(EN_MARKER); + }); +}); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index c4191277ec..cbe9dd7431 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2089,6 +2089,7 @@ const ar = { collapse: "طي المحادثة", maximize: "فتح كصفحة كاملة", open: "فتح المساعد", + planAwaitingApproval: "خطة مقترحة بانتظار موافقتك", }, group: { today: "اليوم", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 3d59c65df8..34af26b5d5 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -1904,6 +1904,7 @@ const de = { collapse: "Chat einklappen", maximize: "Als ganze Seite öffnen", open: "Assistent öffnen", + planAwaitingApproval: "Ein vorgeschlagener Plan wartet auf Ihre Freigabe", }, group: { today: "Heute", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 9e8bc7e04d..c116c5db4c 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2425,6 +2425,8 @@ const en = { collapse: 'Collapse chat', maximize: 'Open full page', open: 'Open assistant', + // objectui#11666 — the launchers' marker while a proposed plan awaits approval. + planAwaitingApproval: 'A proposed plan is waiting for your approval', }, group: { today: 'Today', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index c6195dbaeb..d505582b66 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -1950,6 +1950,7 @@ const es = { collapse: "Contraer el chat", maximize: "Abrir como página completa", open: "Abrir el asistente", + planAwaitingApproval: "Un plan propuesto espera su aprobación", }, group: { today: "Hoy", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index f9b2a3eecb..061451b03a 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -1947,6 +1947,7 @@ const fr = { collapse: "Réduire la discussion", maximize: "Ouvrir en pleine page", open: "Ouvrir l'assistant", + planAwaitingApproval: "Un plan proposé attend votre approbation", }, group: { today: "Aujourd'hui", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index f46bbcc210..52f9eec60e 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -1906,6 +1906,7 @@ const ja = { collapse: "チャットを折りたたむ", maximize: "フルページで開く", open: "アシスタントを開く", + planAwaitingApproval: "提案されたプランが承認待ちです", }, group: { today: "今日", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 2349da623d..fa4d7f707f 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -1904,6 +1904,7 @@ const ko = { collapse: "채팅 접기", maximize: "전체 페이지로 열기", open: "어시스턴트 열기", + planAwaitingApproval: "제안된 계획이 승인을 기다리고 있습니다", }, group: { today: "오늘", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index ed31e3d313..05acfce888 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -1945,6 +1945,7 @@ const pt = { collapse: "Recolher a conversa", maximize: "Abrir como página inteira", open: "Abrir o assistente", + planAwaitingApproval: "Um plano proposto aguarda sua aprovação", }, group: { today: "Hoje", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index fcd6f66ecb..13370818e0 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2005,6 +2005,7 @@ const ru = { collapse: "Свернуть чат", maximize: "Открыть на всю страницу", open: "Открыть ассистента", + planAwaitingApproval: "Предложенный план ожидает вашего подтверждения", }, group: { today: "Сегодня", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index 0bd772c1a9..c114a2a660 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -2084,6 +2084,7 @@ const zh = { collapse: '收起对话', maximize: '以完整页面打开', open: '打开助手', + planAwaitingApproval: '有一个方案等待确认', }, group: { today: '今天', diff --git a/packages/plugin-chatbot/README.md b/packages/plugin-chatbot/README.md index 2cb805b9dd..acf40c70d3 100644 --- a/packages/plugin-chatbot/README.md +++ b/packages/plugin-chatbot/README.md @@ -202,6 +202,14 @@ safe side gutters, stays above the mobile bottom navigation area, and hides the FAB while open so the close button in the panel header is the only active dismiss control. +A host that wants to signal a waiting approval outside the chat can pass +`ChatbotEnhanced` the optional `onPlanApprovalPendingChange` callback. It is +called with `true` while the thread's newest proposed plan still offers +"Build it", and with `false` otherwise: no plan, the user approved it, or its +build ran. It fires once on mount and again on every change. The console uses +it to put a marker on its chat launchers while the chat is closed +(objectui#11666). + During a conversation, the chat surface renders an inline assistant responding indicator while the backend is streaming, keeps message actions quiet until hover/focus, and summarizes backend failures into a compact retryable notice diff --git a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx index e1e1f4ba8c..f665258883 100644 --- a/packages/plugin-chatbot/src/ChatbotEnhanced.tsx +++ b/packages/plugin-chatbot/src/ChatbotEnhanced.tsx @@ -642,6 +642,17 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes void; + /** + * objectui#11666 — reports whether this thread's NEWEST proposed plan is + * still waiting for the user's approval: its card offers "Build it", i.e. + * {@link resolveProposalCardState} reads `pending` for it. Fires once on + * mount and again on every change of that boolean. An earlier plan does not + * count once a newer one exists (the newer card is the one awaiting); an + * approval (the optimistic "Building…" flip) or a build that ran ends it. + * Hosts mirror it outside the chat — the console's launchers show a marker + * while the chat is closed. + */ + onPlanApprovalPendingChange?: (pending: boolean) => void; /** * ADR-0057 P4 — invoked when the user clicks "Open in Builder →" on an `ask` * agent's `suggest_builder` decline. The host opens the build surface seeded @@ -1439,6 +1450,7 @@ const ChatbotEnhanced = React.forwardRef( previewDraftLabel = 'Preview', onDraftArtifacts, onBuildMaterialized, + onPlanApprovalPendingChange, publishDraftsLabel = 'Publish', publishedLabel = 'Published', verifiedLabel = 'Verified', @@ -2173,6 +2185,41 @@ const ChatbotEnhanced = React.forwardRef( // streaming below an older plan card. const planActionsLocked = isLoading; + // objectui#11666 — is this thread's newest proposed plan still waiting for + // the user? Read off the SAME producer the plan card's header and body read + // (`resolveProposalCardState`, objectui#7254), so the host's mirror can + // never disagree with the card: `pending` is exactly the state that renders + // "Build it". The newest plan card is the one awaiting — an older card a + // newer proposal followed is superseded by it. Computed on every render + // rather than memoised: it is one pass over the tool calls, and its inputs + // are memo results whose identity this must not key on (Commandment #10). + let newestPlanId: string | undefined; + for (const message of messages) { + for (const tool of message.toolInvocations ?? []) { + if ((tool.proposedPlan || isUnstructuredBuildProposal(tool)) && tool.toolCallId) { + newestPlanId = tool.toolCallId; + } + } + } + const planApprovalPending = + newestPlanId !== undefined && + resolveProposalCardState({ + replayOutcome: replayOutcomeByProposalId.get(newestPlanId), + built: builtPlanIds.has(newestPlanId), + confirmed: confirmedChangeIds.has(newestPlanId), + approved: approvedPlanIds.has(newestPlanId), + }) === 'pending'; + // The host's callback is read through a ref so the effect keys on the + // boolean alone — a host that passes a fresh function each render must not + // re-announce an unchanged reading. + const onPlanApprovalPendingChangeRef = React.useRef(onPlanApprovalPendingChange); + React.useEffect(() => { + onPlanApprovalPendingChangeRef.current = onPlanApprovalPendingChange; + }, [onPlanApprovalPendingChange]); + React.useEffect(() => { + onPlanApprovalPendingChangeRef.current?.(planApprovalPending); + }, [planApprovalPending]); + const renderToolDetail = (tool: ChatToolInvocation) => { const state = tool.state ?? diff --git a/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.planApprovalPending-11666.test.tsx b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.planApprovalPending-11666.test.tsx new file mode 100644 index 0000000000..e6bf0fd5f0 --- /dev/null +++ b/packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.planApprovalPending-11666.test.tsx @@ -0,0 +1,201 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * objectui#11666 — `onPlanApprovalPendingChange` reports whether the thread's + * NEWEST proposed plan still waits for the user, so a host can mirror it + * outside the chat (the console's launchers show a marker while the chat is + * closed). + * + * The reading must be the plan card's own: `pending` from + * `resolveProposalCardState`, i.e. exactly when the card offers "Build it". + * Every case below asserts the callback AND the card it mirrors, so the two can + * never be pinned apart. The envelopes are the real wire shapes, fed through + * the real mapper (`uiMessagesToChatMessages`), as in the objectui#8343 suite. + */ +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { ChatbotEnhanced } from '../ChatbotEnhanced'; +import { uiMessagesToChatMessages } from '../mapMessages'; + +function proposal(summary: string) { + return { + status: 'blueprint_proposed', + summary, + blueprint: { + objects: [{ name: 'ticket', label: 'Ticket', fields: [{ name: 'subject' }] }], + }, + counts: { objects: 1, views: 1, dashboards: 0, seedData: 0 }, + questions: [], + assumptions: [], + }; +} + +const BUILD_DRAFTED = { + status: 'drafted', + summary: 'Created 1 object', + drafted: [{ type: 'object', name: 'ticket' }], +}; + +const AWAITING_CONFIRMATION = { + status: 'awaiting_confirmation', + message: 'A confirm card for this blueprint was just shown to the user.', +}; + +type Part = { type: string; toolCallId?: string; state?: string; output?: unknown; text?: string }; + +function toolPart(tool: string, toolCallId: string, result: unknown): Part { + return { + type: `tool-${tool}`, + toolCallId, + state: 'output-available', + output: { type: 'text', value: JSON.stringify(result) }, + }; +} + +function thread(...turns: Part[][]) { + return uiMessagesToChatMessages( + turns.map((parts, i) => ({ id: `a${i + 1}`, role: 'assistant' as const, parts })), + ); +} + +/** The last value the host was told. */ +function lastReading(spy: ReturnType): unknown { + return spy.mock.calls.at(-1)?.[0]; +} + +describe('ChatbotEnhanced — onPlanApprovalPendingChange (objectui#11666)', () => { + it('reports true while the proposed plan offers "Build it"', () => { + const spy = vi.fn(); + render( + , + ); + expect(screen.getByTestId('proposed-plan-approve')).toBeInTheDocument(); + expect(lastReading(spy)).toBe(true); + }); + + it('reports false for a thread with no proposed plan', () => { + const spy = vi.fn(); + render( + , + ); + expect(spy).toHaveBeenCalledTimes(1); + expect(lastReading(spy)).toBe(false); + }); + + it('turns false the moment the user approves (the optimistic "Building…" flip)', () => { + const spy = vi.fn(); + render( + , + ); + expect(lastReading(spy)).toBe(true); + + fireEvent.click(screen.getByTestId('proposed-plan-approve')); + + expect(screen.getByTestId('proposed-plan-building')).toBeInTheDocument(); + expect(lastReading(spy)).toBe(false); + }); + + it('turns false once the build ran, and stays true while the build only asked to confirm (#8343)', () => { + const plan = toolPart('propose_blueprint', 'plan-1', proposal('Helpdesk')); + const spy = vi.fn(); + const { rerender } = render( + , + ); + // A confirm-gate preview built nothing — the plan is still the user's to approve. + expect(screen.getByTestId('proposed-plan-approve')).toBeInTheDocument(); + expect(lastReading(spy)).toBe(true); + + rerender( + , + ); + expect(screen.getByTestId('proposed-plan-built')).toBeInTheDocument(); + expect(lastReading(spy)).toBe(false); + }); + + it('follows the NEWEST plan: a newer proposal supersedes the one before it', () => { + const first = toolPart('propose_blueprint', 'plan-1', proposal('Helpdesk')); + const build = toolPart('apply_blueprint', 'apply-1', BUILD_DRAFTED); + const second = toolPart('propose_blueprint', 'plan-2', proposal('Helpdesk with SLAs')); + const spy = vi.fn(); + const { rerender } = render( + , + ); + expect(lastReading(spy)).toBe(false); + + // A newer proposal after a built one: the newer card is the one awaiting. + rerender( + , + ); + expect(screen.getAllByTestId('proposed-plan-approve')).toHaveLength(1); + expect(lastReading(spy)).toBe(true); + }); + + it('an older plan a newer one superseded does not keep the reading alive', () => { + const spy = vi.fn(); + render( + , + ); + expect(lastReading(spy)).toBe(true); + + // Approving the newer plan ends the wait, even though the superseded card + // above it still renders its own (stale) "Build it". + fireEvent.click(screen.getAllByTestId('proposed-plan-approve')[1]); + expect(screen.getAllByTestId('proposed-plan-approve')).toHaveLength(1); + expect(lastReading(spy)).toBe(false); + }); + + it('announces a change once — a fresh callback identity does not re-announce an unchanged reading', () => { + const messages = thread([toolPart('propose_blueprint', 'plan-1', proposal('Helpdesk'))]); + const first = vi.fn(); + const { rerender } = render( + , + ); + expect(first).toHaveBeenCalledTimes(1); + + const second = vi.fn(); + rerender( + , + ); + expect(first).toHaveBeenCalledTimes(1); + expect(second).not.toHaveBeenCalled(); + }); +});