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
18 changes: 18 additions & 0 deletions .changeset/11666-launcher-plan-awaiting-approval.md
Original file line number Diff line number Diff line change
@@ -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.
78 changes: 78 additions & 0 deletions packages/app-shell/src/assistant/assistantBus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
*/

import { useEffect, useSyncExternalStore } from 'react';
import { useAuth } from '@object-ui/auth';

export interface AssistantEditorField {
name: string;
Expand Down Expand Up @@ -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<string, string> = new Map();
const planApprovalListeners = new Set<() => void>();

function subscribePlanApproval(listener: () => void): () => void {
planApprovalListeners.add(listener);
return () => {
planApprovalListeners.delete(listener);
};
}

function getPlanApprovalOwners(): ReadonlyMap<string, string> {
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(
Expand Down
24 changes: 23 additions & 1 deletion packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<boolean | undefined>(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
Expand Down Expand Up @@ -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"
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {};

vi.mock('@object-ui/plugin-chatbot', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
return {
...actual,
ChatbotEnhanced: (props: Record<string, unknown>) => {
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<Record<string, unknown>>();
return { ...actual, useMetadata: () => ({ apps: [] }) };
});
vi.mock('../../../providers/AdapterProvider', async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
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(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<MemoryRouter initialEntries={[`/ai/build/${conversationId}`]}>
<ChatPane
agents={[{ name: 'build', label: 'Builder' } as unknown as AgentDescriptor]}
agentsLoading={false}
agentsError={undefined}
activeAgent="build"
chatApi="/api/v1/ai/agents/build/chat"
apiBase="/api/v1/ai"
conversationId={conversationId}
initialMessages={[]}
pendingFirstMessageRef={{ current: null }}
onSent={vi.fn()}
onShare={vi.fn()}
/>
</MemoryRouter>
</I18nProvider>,
);
}

function renderFab() {
return render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }}>
<ConsoleChatbotFab appLabel="Workspace" onOpenDock={() => {}} />
</I18nProvider>,
);
}

/** 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();
});
});
6 changes: 5 additions & 1 deletion packages/app-shell/src/hooks/useConversationList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { useCallback, useEffect, useState } from 'react';
import { publishPlanApprovalPending } from '../assistant/assistantBus.js';

/**
* One row of the conversation-history list.
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading