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
23 changes: 23 additions & 0 deletions .changeset/console-ai-ux-batch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@object-ui/app-shell": minor
"@object-ui/plugin-chatbot": minor
---

feat(console-ai): package binding chip + inert handoff cards + honest send hint (#2458 / ADR-0057 A1.a)

Three UX improvements from live magic-flow testing:

- **A1.a — package binding chip** (`app-shell`): the build surface header shows
the package the conversation is bound to (`📦 <app>`), or **"New app"** when
unbound — so the edit blast-radius is always visible (Claude-Code-shows-the-repo
idiom). The magic flow starts unbound and binds the moment its build mints a
package (`deriveBoundPackageId` reads `?package=` else the latest draft/handoff
result; unit-tested).
- **UX#5 — only the latest handoff card is actionable** (`plugin-chatbot`): when
a thread accumulates several "Open in Builder →" cards, only the newest stays
clickable; older (superseded) cards' buttons are disabled — derived from
message order, so it survives the navigation the button triggers and the pane
remount that follows. A stale prompt in an older card can't be re-fired.
- **UX#7 — honest send hint** (`plugin-chatbot`): the composer already sends on
plain Enter (Shift+Enter = newline); dropped the misleading `⌘` glyph from the
hint so it no longer implies Cmd+Enter.
73 changes: 72 additions & 1 deletion packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useAuth } from '@object-ui/auth';
import { useObjectTranslation } from '@object-ui/i18n';
import { useObjectTranslation, useObjectLabel } from '@object-ui/i18n';
import { toast } from 'sonner';
import { Package as PackageIcon, Sparkles as SparklesIcon } from 'lucide-react';
import { useAdapter } from '../../providers/AdapterProvider';
import { useMetadata } from '../../providers/MetadataProvider';
import { resolveI18nLabel } from '../../utils';
import { ExcelImportBar } from './ExcelImportBar';
import {
Select,
Expand Down Expand Up @@ -175,6 +178,35 @@ export function hydratedMessagesToChatMessages(messages: HydratedUIMessage[]): C
});
}

/**
* #2458 / ADR-0057 Amendment A1.a — the package a build conversation is bound to:
* the explicit `?package=` edit target (ADR-0070) if present, else the most
* recent package a build/draft in THIS thread produced (`draftReview` /
* `builderHandoff` tool results, newest wins). `undefined` = not-yet-bound —
* the magic-flow "New app" draft, which binds the moment its build mints a
* package. Exported for unit testing.
*/
interface PackageBearingMessage {
toolInvocations?: ReadonlyArray<{
draftReview?: { packageId?: string };
builderHandoff?: { packageId?: string };
}>;
}
export function deriveBoundPackageId(
messages: readonly PackageBearingMessage[],
editPackageId: string | undefined,
): string | undefined {
if (editPackageId) return editPackageId;
let latest: string | undefined;
for (const message of messages) {
for (const tool of message.toolInvocations ?? []) {
const pkg = tool.draftReview?.packageId || tool.builderHandoff?.packageId;
if (pkg) latest = pkg;
}
}
return latest;
}

function firstUserMessageText(messages: HydratedUIMessage[]): string | undefined {
const message = messages.find((item) => item.role === 'user');
const text = message?.parts
Expand Down Expand Up @@ -1452,6 +1484,25 @@ export function ChatPane({
agentRoute: activeAgent ? agentRouteName(activeAgent) : undefined,
});

// #2458 / ADR-0057 Amendment A1.a — the package this build conversation is
// bound to, surfaced as a header chip so the edit blast-radius is always
// visible (the Claude-Code-shows-the-repo idiom). The magic flow starts
// unbound ("New app") and binds the moment its build mints a package.
const isBuildSurface = activeAgent ? agentRouteName(activeAgent) === 'build' : false;
const { apps: metadataApps } = useMetadata();
const { appLabel } = useObjectLabel();
const boundPackageId = useMemo(
() => deriveBoundPackageId(messages as unknown as readonly PackageBearingMessage[], editPackageId),
[messages, editPackageId],
);
const boundPackageLabel = useMemo(() => {
if (!boundPackageId) return undefined;
const app = (metadataApps ?? []).find(
(a) => (a as { _packageId?: string })._packageId === boundPackageId,
);
return app ? appLabel({ name: app.name, label: resolveI18nLabel(app.label, t) }) : boundPackageId;
}, [boundPackageId, metadataApps, appLabel, t]);

const headerSlot = (
<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">
<div className="flex min-w-0 flex-1 items-center gap-2">
Expand Down Expand Up @@ -1517,6 +1568,26 @@ export function ChatPane({
{activeAgentLabel}
</span>
)}
{isBuildSurface ? (
boundPackageId ? (
<span
data-testid="ai-build-package-chip"
title={boundPackageId}
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"
>
<PackageIcon className="size-3 shrink-0" />
<span className="max-w-[10rem] truncate">{boundPackageLabel}</span>
</span>
) : (
<span
data-testid="ai-build-package-chip"
className="inline-flex items-center gap-1 rounded-md border border-dashed px-2 py-0.5 text-xs text-muted-foreground"
>
<SparklesIcon className="size-3 shrink-0" />
{t('console.ai.newApp', { defaultValue: 'New app' })}
</span>
)
) : null}
</div>
<div className="flex shrink-0 items-center gap-1">
{showDebug && onDebug ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* #2458 / ADR-0057 Amendment A1.a — the package a build conversation is bound to,
* shown as the header chip. Explicit `?package=` (Edit-with-AI) wins; otherwise
* the most recent package a build/draft in the thread produced; undefined = the
* not-yet-bound "New app" draft (the magic flow's start state).
*/
import { describe, it, expect } from 'vitest';
import { deriveBoundPackageId } from '../AiChatPage';
import type { ChatMessage } from '@object-ui/plugin-chatbot';

const msg = (toolInvocations: unknown[]): ChatMessage =>
({ id: 'm', role: 'assistant', content: '', toolInvocations } as unknown as ChatMessage);

describe('deriveBoundPackageId', () => {
it('prefers the explicit editPackageId (Edit-with-AI) over anything in messages', () => {
const messages = [msg([{ draftReview: { packageId: 'app.drafted' } }])];
expect(deriveBoundPackageId(messages, 'app.edit')).toBe('app.edit');
});

it('unbound while nothing has been built → undefined ("New app")', () => {
expect(deriveBoundPackageId([], undefined)).toBeUndefined();
expect(deriveBoundPackageId([msg([{ someOther: true }])], undefined)).toBeUndefined();
});

it('binds to the package a build/draft produced (draftReview or builderHandoff)', () => {
expect(deriveBoundPackageId([msg([{ draftReview: { packageId: 'app.inventory' } }])], undefined)).toBe(
'app.inventory',
);
expect(deriveBoundPackageId([msg([{ builderHandoff: { prompt: 'x', packageId: 'app.crm' } }])], undefined)).toBe(
'app.crm',
);
});

it('takes the MOST RECENT package when several appear (newest wins)', () => {
const messages = [
msg([{ draftReview: { packageId: 'app.first' } }]),
msg([{ draftReview: { packageId: 'app.second' } }]),
];
expect(deriveBoundPackageId(messages, undefined)).toBe('app.second');
});
});
52 changes: 40 additions & 12 deletions packages/plugin-chatbot/src/ChatbotEnhanced.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -610,6 +610,8 @@ export interface ChatbotEnhancedProps extends React.HTMLAttributes<HTMLDivElemen
builderHandoffTitleLabel?: string;
/** Label for the handoff card's primary action button (default "Open in Builder →"). */
builderHandoffOpenLabel?: string;
/** Tooltip on a superseded (older) handoff card's disabled button (default "A newer request is available"). */
builderHandoffSupersededTitle?: string;
/** Label for the publish-drafts button (default "Publish"). */
publishDraftsLabel?: string;
/** Label for the published-state badge that replaces the button (default "Published"). */
Expand Down Expand Up @@ -1178,6 +1180,7 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
planTitleLabel = 'Proposed plan',
builderHandoffTitleLabel = 'Build this in the Builder',
builderHandoffOpenLabel = 'Open in Builder →',
builderHandoffSupersededTitle = 'A newer request is available',
onOpenBuilder,
planExtendLabel = 'Adding to existing app',
planQuestionsLabel = 'Confirm before building',
Expand Down Expand Up @@ -1562,6 +1565,21 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
return ids;
}, [messages]);

// #2458 UX#5 — only the LATEST "Open in Builder →" handoff card stays
// actionable; older cards carry a now-stale prompt and go inert, so a thread
// that accumulated several can't re-fire an outdated request. Derived from
// message order (not click state), so it survives the navigation the button
// triggers and the pane remount that follows.
const latestHandoffToolCallId = React.useMemo(() => {
let latest: string | undefined;
for (const message of messages) {
for (const tool of message.toolInvocations ?? []) {
if (tool.builderHandoff && tool.toolCallId) latest = tool.toolCallId;
}
}
return latest;
}, [messages]);

// A granular 确认修改 card collapses to a static 已确认 badge once the change
// has been applied — i.e. a LATER invocation of the SAME tool committed (no
// longer a changes_proposed preview). Positional + per-tool so an earlier
Expand Down Expand Up @@ -1924,15 +1942,25 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
{builderHandoffTitleLabel}
</span>
<p className="text-xs text-muted-foreground">{tool.builderHandoff.prompt}</p>
<button
type="button"
onClick={() => onOpenBuilder?.(tool.builderHandoff!)}
disabled={!onOpenBuilder}
data-testid="builder-handoff-open"
className="inline-flex w-fit items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{builderHandoffOpenLabel}
</button>
{(() => {
// #2458 UX#5 — only the LATEST handoff card stays actionable;
// an older (superseded) card's prompt is stale, so its button is
// disabled rather than re-firing an outdated request.
const superseded =
!!latestHandoffToolCallId && tool.toolCallId !== latestHandoffToolCallId;
return (
<button
type="button"
onClick={() => onOpenBuilder?.(tool.builderHandoff!)}
disabled={!onOpenBuilder || superseded}
data-testid={superseded ? 'builder-handoff-superseded' : 'builder-handoff-open'}
title={superseded ? builderHandoffSupersededTitle : undefined}
className="inline-flex w-fit items-center gap-1 rounded-md bg-primary px-2.5 py-1 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/90 disabled:cursor-not-allowed disabled:opacity-50"
>
{builderHandoffOpenLabel}
</button>
);
})()}
</div>
) : null}
{tool.proposedPlan ? (
Expand Down Expand Up @@ -2661,13 +2689,13 @@ const ChatbotEnhanced = React.forwardRef<HTMLDivElement, ChatbotEnhancedProps>(
))}
</select>
) : null}
{/* #2458 UX#7 — the composer sends on PLAIN Enter (Shift+Enter =
newline); the old `⌘` glyph implied Cmd+Enter and misled users.
Show the real keys. */}
<span
className="hidden items-center gap-1 text-[10px] text-muted-foreground sm:inline-flex"
aria-hidden="true"
>
<kbd className="inline-flex h-4 items-center rounded border bg-muted px-1 font-mono text-[10px] leading-none">
⌘
</kbd>
<CornerDownLeft className="h-3 w-3" />
<span className="opacity-70">{L.sendHint}</span>
</span>
Expand Down
32 changes: 32 additions & 0 deletions packages/plugin-chatbot/src/__tests__/ChatbotEnhanced.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,38 @@ describe('ChatbotEnhanced (AI Elements composition)', () => {
});
});

it('keeps only the LATEST handoff card actionable; older ones are superseded/inert (#2458 UX#5)', () => {
const onOpenBuilder = vi.fn();
const messages: ChatMessage[] = [
{
id: 'a1',
role: 'assistant',
content: '',
toolInvocations: [
{ toolCallId: 't1', toolName: 'suggest_builder', state: 'output-available', builderHandoff: { prompt: 'first request' } },
],
},
{
id: 'a2',
role: 'assistant',
content: '',
toolInvocations: [
{ toolCallId: 't2', toolName: 'suggest_builder', state: 'output-available', builderHandoff: { prompt: 'second request' } },
],
},
];
render(<ChatbotEnhanced messages={messages} onOpenBuilder={onOpenBuilder} />);
// The older card (t1) is inert; the latest (t2) is actionable.
const superseded = screen.getByTestId('builder-handoff-superseded');
expect(superseded).toBeDisabled();
const actionable = screen.getByTestId('builder-handoff-open');
fireEvent.click(actionable);
expect(onOpenBuilder).toHaveBeenCalledWith({ prompt: 'second request' });
// Clicking the superseded one does nothing (disabled).
fireEvent.click(superseded);
expect(onOpenBuilder).toHaveBeenCalledTimes(1);
});

it('disables "Open in Builder" when no host wired onOpenBuilder', () => {
const messages: ChatMessage[] = [
{
Expand Down
Loading