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
72 changes: 72 additions & 0 deletions .changeset/1984-v1-honest-chips-and-ui-fragments.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
'@object-ui/i18n': minor
'@object-ui/core': minor
'@object-ui/app-shell': minor
'@object-ui/plugin-chatbot': minor
'@object-ui/plugin-dashboard': minor
'@object-ui/plugin-report': minor
'@object-ui/console': minor
---

Six user-visible fixes across the maker surface, the assistant rail and the
dataset captions.

**The maker's start chips now promise only what ADR-0112 v1 builds
(cloud#1984).** Two of the five asked for automation the first version has no
flows or actions for — the ticket chip said 「状态流转」, the inventory chip said
「低库存预警」 — and the measured behaviour was not a refusal but a silent
degrade: a status kanban and a low-stock view. The chip promised an alert and
delivered a page. All five are reworded in all ten packs (and in the call-site
`defaultValue` fallbacks, which are a second copy of the same strings) to ask
for objects, fields, views, pages, dashboards and sample data, keeping each a
real business scenario — the ticket chip now asks for a status field and a board
grouped by it, the inventory chip for a view that filters below the reorder
point. A note beside the keys says to revert when v2 re-adds flows.

**Five newer AI tools get their step labels (objectui#7481).** A zh conversation
read `✓ Get authoring rules 已完成` between 「读取元数据结构」 and 「列出对象」:
`get_authoring_rules` (cloud#1837), plus `load_tools`, `open_record`,
`test_flow` and `toggle_flow`, are registered by the cloud AI runtime but are
newer than the pinned spec's tool registry, so they had no `chatbot.tool.*`
entry in any pack and fell through to the English title-caser.

**The assistant rail follows the thread when you send (objectui#7480).** The
rail and the full-page maker are the same component; what differs is width. A
reply that still ends on screen in the wide column runs two or three times
taller in a ~360px rail, so `StickToBottom`'s lock is escaped by the time the
user types and the new bubble, the tool steps and the streaming answer all land
below the fold. Every send path now re-arms the lock — including the plan-card
"Build it" and 确认修改 approvals, whose own code comments already named this
miss. Message APPENDS deliberately do not, so a user reading back through the
thread mid-answer is never yanked to the bottom.

**Console toasts move off the assistant composer (objectui#7482).** 「客户更新
成功」 sat on the ChatDock composer's send button and stayed there. One defect,
two symptoms: `apps/console` pinned the toaster to `bottom-right` — an override
that predates ADR-0057 P3a — so a toast both covered the button and, because
sonner pauses a toast's dismiss timer while the pointer is inside the toaster
region, never got to run its 4s timer with a pointer resting on the composer
underneath. The override is gone; the console takes `ConsoleToaster`'s own
documented top-right anchor, and the 4s success duration is now pinned.

**Built-in aggregate captions follow the locale everywhere (objectui#7534).**
objectui#7258 taught `buildChartSeries()` to resolve a server-minted default
measure through the locale map, so a chart legend read `计数` while the table
beneath it, the KPI caption, the pivot header and the dataset preview still
printed the server's hard-coded English `Count`. `buildDatasetFieldHelpers()`
takes the same optional `builtinAggregateLabels`, resolving through the one
`resolveMeasureLabel` order, and the five call sites pass it. Omitting the
argument reproduces the previous output byte for byte, and an author-declared
measure still keeps its own label verbatim (objectui#4106).

**The activity feed stops asking for an object the environment does not have
(objectui#7476).** A tenant environment has no `sys_activity`, so every page
load issued a request that 404'd. Everything downstream was already correct —
the adapter memoizes the missing collection, its logger demotes the failure, the
feed retires as an ANSWER and the panel renders its earned empty state — so what
is left is the request itself, and `data-objectstack` states the rule for it:
the cure for a doomed request is not issuing it. New `useObjectPresence` reads
the object registry the shell loads for the nav anyway; only a registry that has
ANSWERED and lists other objects without this one skips the read. Every
uncertainty — no provider, empty registry, still loading, errored — reads as
before, because a wrong skip would cost a real deployment its feed.
14 changes: 13 additions & 1 deletion apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,19 @@ function BrandingSync() {
export function App() {
return (
<AuthProvider authUrl={AUTH_URL}>
<ConsoleToaster position="bottom-right" />
{/* objectui#7482 — no `position` override: the console takes
`ConsoleToaster`'s own documented top-right anchor.

`bottom-right` predates the ChatDock (ADR-0057 P3a) and the FAB that
launches it, and the bottom-right corner now belongs to them: a
success toast landed exactly on the assistant composer's send button.
Two things went wrong, not one. It COVERED the button — and because
sonner pauses a toast's dismiss timer while the pointer is over the
toaster region (`expanded || interacting || isDocumentHidden`), a
pointer resting on the composer under it kept 「客户更新成功」 on
screen indefinitely, so the only way out was the × . The toaster's
4s default was never wrong; it just never got to run. */}
<ConsoleToaster />
<MetadataHmrReloader />
<BrowserRouter basename={BASENAME}>
<BrandingSync />
Expand Down
108 changes: 108 additions & 0 deletions packages/app-shell/src/chrome/ConsoleToaster.autoDismiss-7482.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
/**
* 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#7482 — 「客户更新成功」 was still on screen 90 seconds after a record
* save, sitting exactly on the assistant rail's send button; only the × closed
* it.
*
* Two facts, and the second explains the first. The toaster has always carried
* a 4s default and `crud_success` passes no duration of its own, so nothing
* about the DURATION was wrong. What was wrong is where the toaster was
* anchored: `apps/console` overrode it to `bottom-right`, the corner ADR-0057
* later gave to the ChatDock composer and its FAB. Sonner pauses a toast's
* dismiss timer whenever the pointer is inside the toaster region —
*
* if (expanded || interacting || isDocumentHidden) pauseTimer();
* else startTimer(); (sonner 2.0.8, Toast effect)
*
* — and `expanded` is set by the region's own `onMouseEnter`/`onMouseMove`. A
* pointer resting on the composer underneath therefore held the timer at zero
* for as long as it stayed there. Covering the button and never dismissing were
* one defect, not two.
*
* So this file pins the two properties the fix rests on: the default anchor is
* the top-right corner (nothing interactive lives there), and a success toast
* really does dismiss itself inside the 3–5s band the card asked for.
*/
import '@testing-library/jest-dom/vitest';
import * as React from 'react';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act, cleanup, waitFor } from '@testing-library/react';
import { toast } from 'sonner';
import { ConsoleToaster } from './ConsoleToaster.js';
import { ThemeProvider } from './ThemeProvider.js';

const renderToaster = (props: Record<string, unknown> = {}) =>
render(
<ThemeProvider>
<ConsoleToaster {...props} />
</ThemeProvider>,
);

afterEach(() => {
cleanup();
});

/** Sonner only mounts the positioned `<ol>` once there is a toast in it. */
async function anchorOf(props: Record<string, unknown> = {}): Promise<[string | null, string | null]> {
renderToaster(props);
act(() => {
toast.success('anchor probe');
});
const region = await waitFor(() => {
const el = document.querySelector('[data-sonner-toaster]');
expect(el, 'sonner did not mount its toaster region').toBeTruthy();
return el as HTMLElement;
});
return [region.getAttribute('data-y-position'), region.getAttribute('data-x-position')];
}

describe('ConsoleToaster anchor (objectui#7482)', () => {
it('defaults to the top-right corner, away from the assistant composer', async () => {
expect(await anchorOf()).toEqual(['top', 'right']);
});

it('is still overridable — the default is a default, not a lock', async () => {
// The component's contract has always been "spread `{...props}` wins".
// Losing that would be a different regression from the one above.
expect(await anchorOf({ position: 'bottom-left' })).toEqual(['bottom', 'left']);
});
});

describe('ConsoleToaster success toasts dismiss themselves (objectui#7482)', () => {
beforeEach(() => {
// Real timers advance sonner's own `setTimeout`; fake ones let this run in
// milliseconds instead of seconds.
vi.useFakeTimers({ shouldAdvanceTime: true });
});

afterEach(() => {
vi.useRealTimers();
});

it('the card`s own toast is gone within the 3–5s band', async () => {
renderToaster();
act(() => {
toast.success('客户更新成功');
});
expect(await screen.findByText('客户更新成功')).toBeInTheDocument();

// Still there just before the band closes…
act(() => {
vi.advanceTimersByTime(2_500);
});
expect(screen.queryByText('客户更新成功')).toBeInTheDocument();

// …and gone after it (plus sonner's unmount delay).
act(() => {
vi.advanceTimersByTime(3_000);
});
await waitFor(() => expect(screen.queryByText('客户更新成功')).not.toBeInTheDocument());
});
});
20 changes: 19 additions & 1 deletion packages/app-shell/src/chrome/ConsoleToaster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ export function ConsoleToaster(props: ToasterProps) {
// UX defaults chosen for an enterprise console — match the Linear /
// Notion pattern users expect. Callers can still override any of
// these via the spread `{...props}` below.
//
// objectui#7482 — `top-right` is load-bearing, not cosmetic. The
// bottom-right corner belongs to the ChatDock's composer and the FAB
// that launches it (ADR-0057 P3a/P3b), and a toaster anchored there does
// more than overlap them: sonner pauses a toast's dismiss timer while
// the pointer is inside the toaster region (`expanded || interacting ||
// isDocumentHidden` in its Toast effect), so a pointer resting on the
// composer underneath keeps the toast on screen until the user clicks ×.
// Override the position only onto a corner nothing interactive occupies.
position="top-right"
closeButton
richColors
Expand All @@ -39,7 +48,16 @@ export function ConsoleToaster(props: ToasterProps) {
}}
toastOptions={{
// 4s default keeps actionable toasts visible long enough to
// click an Undo button without feeling sticky.
// click an Undo button without feeling sticky. objectui#7482 asked for
// 3–5s on success toasts and this already sits in that band; it is
// pinned in `__tests__/ConsoleToaster.autoDismiss-7482` because
// nothing checked it, and a success toast that outlives its own
// information is what that card was reported as.
//
// NOT split per intent ("errors may persist"): sonner has no per-type
// duration on `Toaster`, so the only way to say it is a `duration` at
// each of the ~100 `toast.error(...)` call sites. `closeButton` below
// already gives every toast a manual exit.
duration: 4000,
classNames: {
toast:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* 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#7482 ratchet — no console may re-anchor the toaster onto the corner
* the assistant lives in.
*
* `ConsoleToaster.autoDismiss-7482` next door pins the COMPONENT: its default
* is top-right, and a success toast dismisses itself inside the 3–5s band the
* card asked for. Neither assertion can see the MOUNT, and the mount is where
* the defect actually was — `apps/console/src/App.tsx` carried a
* `position="bottom-right"` override that predates ADR-0057 P3a, which dropped
* every toast onto the ChatDock composer's send button. That both covered the
* button and, because sonner pauses a toast's dismiss timer while the pointer
* is inside the toaster region (`expanded || interacting || isDocumentHidden`),
* stopped the 4s default from ever running. One defect, two symptoms.
*
* Read from SOURCE rather than by rendering `<App>`: answering a question about
* one prop should not need the router, the auth provider and the whole console
* graph. Same shape as `providers/expressionUser.mountSites.ratchet`.
*
* If this fails: the toaster may be moved, but only onto a corner nothing
* interactive occupies. Bottom-right is the FAB and the assistant composer.
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const here = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(here, '../../../..');

/** Every `<ConsoleToaster …>` element in a file, as written. */
const MOUNT_RE = /<ConsoleToaster\b[^>]*\/?>/g;

/** The consoles that mount the toaster. One today; the list is the point. */
const MOUNT_FILES = ['apps/console/src/App.tsx'] as const;

describe('console toaster anchor (objectui#7482)', () => {
it.each(MOUNT_FILES)('%s mounts exactly one ConsoleToaster', (rel) => {
const source = readFileSync(path.join(repoRoot, rel), 'utf8');
expect(source.match(MOUNT_RE) ?? []).toHaveLength(1);
});

it.each(MOUNT_FILES)('%s passes no `position` — the component owns the anchor', (rel) => {
const source = readFileSync(path.join(repoRoot, rel), 'utf8');
const mount = (source.match(MOUNT_RE) ?? [])[0];
expect(mount, 'the assistant composer and the FAB own bottom-right').not.toMatch(/bottom-right/);
expect(mount).not.toMatch(/\bposition\s*=/);
});

it('the control: the matcher WOULD have caught the override it replaced', () => {
// Non-vacuity — a matcher that has silently stopped matching passes both
// assertions above while checking nothing.
const retired = ' <ConsoleToaster position="bottom-right" />';
const found = retired.match(MOUNT_RE) ?? [];
expect(found).toHaveLength(1);
expect(found[0]).toMatch(/bottom-right/);
expect(found[0]).toMatch(/\bposition\s*=/);
});
});
20 changes: 15 additions & 5 deletions packages/app-shell/src/console/ai/AiChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2581,12 +2581,22 @@ function metadataAssistantSuggestions(t: TranslationFn): string[] {
// Creation-first starters: the authoring agent's job is to BUILD from a
// natural-language description (the magic moment), so the empty-state nudges
// toward "describe a system" rather than inspecting existing schema.
//
// cloud#1984 — these are the PRODUCT's own recommendations, so they may only
// ask for what ADR-0112 v1 builds: objects, fields, views (grid / kanban /
// calendar / gallery), pages, dashboards and sample data. No wording that
// promises autonomous behaviour (alert / remind / notify / automate / status
// workflow): v1 has no flows, actions or schedules, and the measured
// behaviour is that the model silently DEGRADES such a request into a board
// or a filtered view — so the chip promises an alert and delivers a page.
// REVERT to the automation wording when ADR-0112 v2 re-adds flows and
// actions. The `defaultValue`s below are byte-equal to the `en` pack.
return [
t('console.ai.suggestions.metadataAssistant.buildCrm', { defaultValue: 'Build a sales CRM — customers, contacts, and a deal pipeline I can total by stage.' }),
t('console.ai.suggestions.metadataAssistant.buildApp', { defaultValue: 'Create a project tracker — projects, tasks with owners and due dates, and a board by status.' }),
t('console.ai.suggestions.metadataAssistant.buildFlow', { defaultValue: 'Design a support desk — tickets with priority, a status workflow, and customer links.' }),
t('console.ai.suggestions.metadataAssistant.buildInventory', { defaultValue: 'Build an inventory app — products, stock levels, suppliers, and low-stock visibility.' }),
t('console.ai.suggestions.metadataAssistant.buildRecruiting', { defaultValue: 'Make an applicant tracker — candidates, open roles, interview stages, and notes.' }),
t('console.ai.suggestions.metadataAssistant.buildCrm', { defaultValue: 'Build a sales CRM — customers, contacts, and deals with a stage field, plus a dashboard that totals deal value by stage.' }),
t('console.ai.suggestions.metadataAssistant.buildApp', { defaultValue: 'Create a project tracker — projects, tasks with owners and due dates, a board grouped by status, and a calendar of due dates.' }),
t('console.ai.suggestions.metadataAssistant.buildFlow', { defaultValue: 'Design a support desk — tickets with priority and status fields, a board grouped by status, and links to customers.' }),
t('console.ai.suggestions.metadataAssistant.buildInventory', { defaultValue: 'Build an inventory app — products, stock levels, suppliers, and a view that filters the items below their reorder point.' }),
t('console.ai.suggestions.metadataAssistant.buildRecruiting', { defaultValue: 'Make an applicant tracker — candidates, open roles, an interview-stage field, and a board grouped by stage.' }),
];
}

Expand Down
Loading
Loading