Skip to content
15 changes: 15 additions & 0 deletions .changeset/10899-assistant-fab-yields-to-open-dock.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the assistant FAB is hidden while the chat dock is open (objectui#10899)

The console's round assistant button is the chat dock's collapsed affordance
(ADR-0057), but `ConsoleLayout` rendered it whether the dock was collapsed or
open. With the right rail open inside an app, the `fixed bottom-6 right-6`
button sat on top of the rail composer's send button, so only the Enter key
could send.

The FAB now renders only while the dock is collapsed — the same rule the Studio
dock's `ChatDockLauncher` already follows. The open rail and the mobile sheet
keep their own collapse and close controls.
19 changes: 19 additions & 0 deletions .changeset/10899-chat-step-turns-no-empty-blocks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/plugin-chatbot': patch
---

fix(plugin-chatbot): a reloaded multi-step build no longer shows an empty 「执行过程」 block under every step (objectui#10899)

The conversation store persists each step of an agent turn as its own assistant
row whose only text is a `(called …)` placeholder, so a reloaded build (the
in-app dock, the Studio copilot) hydrates into one message per step. Each one
rendered its tool row, then the italic 执行过程 note, then the message action
bar — invisible until hover but full height — so every step read as an empty
block with a large gap.

- The 执行过程 note is now only the fallback for a placeholder turn that shows
nothing else. A turn whose tool row, build panel or design panel is on screen
does not repeat it.
- The copy / regenerate bar renders only for a turn with visible prose. A
prose-less step has nothing to copy, and Copy used to copy the internal
placeholder.
23 changes: 23 additions & 0 deletions .changeset/10899-dataset-tile-read-refusal-no-access.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
'@object-ui/data-objectstack': patch
'@object-ui/plugin-dashboard': patch
'@object-ui/i18n': patch
---

fix(data-objectstack, plugin-dashboard): a dataset tile the viewer may not read shows a localized "no access" state (objectui#10899)

When the analytics read admission refused a dashboard dataset query — `403`
with ADR-0112 `PERMISSION_DENIED` — `queryDataset` had no branch for that code,
so it threw the generic `Dataset query failed: 403 Forbidden — [Analytics]
Access denied: …` string, and `DatasetWidget` printed it verbatim in a red
alert. The list view over the same object already says 「无权访问」.

- `queryDataset` now throws a typed `AnalyticsForbiddenError` for that code,
carrying `httpStatus: 403` and `code: 'PERMISSION_DENIED'` (plus the server's
code and message for diagnostics). A code-less 403 — no ObjectStack route
wrote it — keeps the generic error.
- `DatasetWidget` classifies a failed query with the shared `classifyLoadError`
and, for `forbidden`, renders a localized no-access state
(`dashboard.widgetForbiddenTitle` / `dashboard.widgetForbiddenMessage`, new in
all ten packs) instead of the exception text. Every other failure keeps the
detailed alert.
20 changes: 20 additions & 0 deletions .changeset/10899-failed-comment-never-shown-as-sent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/app-shell': patch
'@object-ui/plugin-detail': patch
'@object-ui/i18n': patch
---

fix(app-shell, plugin-detail): a record comment whose write fails is never shown as sent (objectui#10899)

The record page's discussion writers appended the new comment (or reply) to the
panel first and then fired the `sys_comment` create, swallowing its rejection.
On a tenant with no `sys_comment` the write answered 404 while the panel showed
the comment and a bumped count, with no error — and the comment was gone after a
reload.

`RecordDetailView` now appends a comment or reply only after its write resolves.
A failed write raises a localized error toast (`detail.commentFailed`, new in all
ten packs) and rejects back to the composer. The composers — the activity
timeline's comment box, the threaded reply input, and `RecordComments` — now
treat a rejected `onAddComment` / `onAddReply` explicitly as "not written": they
keep the draft for a retry instead of letting the rejection escape unhandled.
19 changes: 19 additions & 0 deletions .changeset/10899-marketplace-update-is-higher-version.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the marketplace offers an "update" only for a HIGHER version (objectui#10899)

The environment marketplace detail page decided "update available" — the amber
`Update available → vX` badge, the primary button's `Update` label, and the
install dialog's per-environment `Update → vX` hint — by comparing the installed
version with the latest approved one using `!==`. An environment holding a newer
version than the latest approved one (a draft installed ahead of review) was
therefore told `Installed v1.1.0 · Update available → v1.0.0`, and the button
offered to "update" it to the older version.

Both call sites now ask `isNewerVersion`, which orders the two by SemVer 2.0.0
precedence — the grammar the package manifest contract declares for `version`.
Only a version of higher precedence is an update; an equal, older or
non-SemVer pair offers none, so the page never guesses an order the contract did
not give it.
18 changes: 18 additions & 0 deletions .changeset/10899-studio-bridges-need-authoring-capability.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/app-shell': patch
---

fix(app-shell): the Studio edit/design affordances follow the server's authoring capability (objectui#10899)

A page's "Edit in Studio" pencil and the top bar's "Design in Studio" hammer
were gated on the workspace-admin ROLE alone. An organization owner is a
workspace admin, but framework's `organization_admin` permission set
deliberately withholds `manage_metadata` (ADR-0066), and
`GET /api/v1/auth/me/permissions` reports that. So on the cloud control plane a
freshly signed-up customer was offered the platform's own page metadata editor.

Both affordances now also require `manage_metadata` through
`useCanAuthorMetadata` — the server's answer, the same one HomePage's builder
CTAs already consume. A server that reports no `systemPermissions` at all
(predating ADR-0066) keeps today's admin-only behaviour, since an unknown answer
fails open; the server refuses the write either way.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import { MarketplaceAccessDenied } from './MarketplaceAccessDenied.js';
import { MarketplaceResolving } from './MarketplaceResolving.js';
import { MarketplaceDisabled } from './MarketplaceDisabled.js';
import { localizePackage } from './usePackageL10n.js';
import { isNewerVersion } from './versionPrecedence.js';
import {
getMarketplacePackage,
installPackage,
Expand Down Expand Up @@ -633,11 +634,14 @@ export function MarketplacePackagePage() {
// PD4 (ADR-0025 §3.11): code-bearing packages must disclose + be acknowledged.
const containsCode = !!pkg.latest_version?.contains_code;
// ADR-0010 version lifecycle: installed cloud env is on an OLDER version than
// the package's latest published → surface an update affordance.
// the package's latest published → surface an update affordance. OLDER is
// SemVer precedence, not inequality (objectui#10899): an env holding a newer
// version than the latest approved one is not offered a "downgrade update".
// The `'installed'` sentinel (version unknown) has no precedence, so it never
// claims an update either.
const cloudUpdateAvailable = !!cloudInstalledVersion
&& cloudInstalledVersion !== 'installed'
&& !!latestVersion
&& cloudInstalledVersion !== latestVersion;
&& isNewerVersion(latestVersion, cloudInstalledVersion);

const supportsLocal = getRuntimeConfig().features.installLocal;
const primaryDisabled = !latestVersion || installingLocal || installing || (!supportsLocal && !!cloudInstalledVersion && !cloudUpdateAvailable);
Expand Down Expand Up @@ -952,8 +956,9 @@ export function MarketplacePackagePage() {
<SelectContent>
{envs.map((e) => {
const installedHere = envInstallMap[e.id];
// Same precedence rule as `cloudUpdateAvailable` above.
const hasUpdate = !!installedHere && !!latestVersion
&& installedHere !== 'installed' && installedHere !== latestVersion;
&& isNewerVersion(latestVersion, installedHere);
return (
<SelectItem key={e.id} value={e.id}>
{e.display_name || e.hostname || e.id}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The environment marketplace detail page offers an "update" only for a HIGHER
* version (objectui#10899 item 7, carried from cloud#2432).
*
* Measured on the 2026-09-28 local E2E: an environment that held v1.1.0 (a
* draft installed ahead of review) while the latest approved version was
* v1.0.0 read `Installed · v1.1.0 / Update available → v1.0.0`, and the
* primary button offered to "update" it — to the older version. The page
* compared the two with `!==`.
*
* Real subject: `MarketplacePackagePage` over the genuine runtime-config boot
* (the same harness the sibling `*-5620` / `*.guardOrder` suites use), with the
* marketplace API stubbed at its module boundary. `t` echoes the KEY so the
* assertion names which state the page reached, not which English string.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen } from '@testing-library/react';

vi.mock('react-router-dom', () => ({
useNavigate: () => vi.fn(),
useParams: () => ({ packageId: 'com.local.sample' }),
}));

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useWorkspaceAdminStatus: () => ({ isAdmin: true, isResolved: true }),
}));

vi.mock('@object-ui/i18n', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useObjectTranslation: () => ({
t: (key: string) => `«${key}»`,
language: 'en',
}),
}));

const getMarketplacePackage = vi.fn();
const getCloudInstallationInfo = vi.fn();

vi.mock('../marketplaceApi', () => ({
getMarketplacePackage: (...a: unknown[]) => getMarketplacePackage(...a),
getCloudInstallationInfo: (...a: unknown[]) => getCloudInstallationInfo(...a),
listLocalInstalls: async () => [],
listMarketplacePackages: async () => ({ items: [] }),
listOrgPackages: async () => ({ items: [] }),
listInstalledPackages: async () => ({ items: [] }),
installPackage: async () => ({}),
installLocal: async () => ({}),
uninstallLocal: async () => ({}),
listCloudEnvironments: async () => [],
listInstallableOrgIds: async () => [],
cloudConsoleUrl: () => '',
reseedSampleData: async () => ({ ok: true }),
purgeSampleData: async () => ({ ok: true }),
reseedLocalSampleData: async () => ({ ok: true }),
purgeLocalSampleData: async () => ({ ok: true }),
}));

vi.mock('../../../assistant/assistantBus', () => ({ emitMetadataRefresh: () => {} }));
vi.mock('../../../providers/MetadataProvider', () => ({ useMetadata: () => ({ refresh: () => {} }) }));
vi.mock('../../../components/SuggestedBindingsPanel', () => ({ SuggestedBindingsPanel: () => null }));

import { initRuntimeConfig, resetRuntimeConfigForTesting } from '../../../runtime-config';
import { MarketplacePackagePage } from '../MarketplacePackagePage';

/** An env console: marketplace mounted, cloud install path (no local install). */
async function bootEnvConsole() {
resetRuntimeConfigForTesting();
vi.stubGlobal(
'fetch',
vi.fn(async () => ({
ok: true,
status: 200,
json: async () => ({
cloudUrl: 'https://cloud.objectos.ai',
singleEnvironment: true,
defaultEnvironmentId: 'env_1',
features: { installLocal: false, marketplace: true, aiStudio: true, autoPublishAiBuilds: true },
branding: { productName: 'ObjectOS', productShortName: 'ObjectOS' },
}),
})),
);
await initRuntimeConfig();
}

function packageWithLatest(version: string) {
return {
package: {
id: 'pkg_1',
manifest_id: 'com.local.sample',
display_name: 'Sample',
description: 'A sample package.',
latest_version: { version },
},
versions: [{ version }],
};
}

beforeEach(() => {
getMarketplacePackage.mockReset();
getCloudInstallationInfo.mockReset();
});

afterEach(() => {
vi.unstubAllGlobals();
resetRuntimeConfigForTesting();
});

describe('installed version is NEWER than the latest approved one', () => {
it('offers no update — no badge, and the button reads Installed', async () => {
await bootEnvConsole();
getMarketplacePackage.mockResolvedValue(packageWithLatest('1.0.0'));
getCloudInstallationInfo.mockResolvedValue({ version: '1.1.0' });

render(<MarketplacePackagePage />);

await screen.findByText('Sample');
// The installed badge is there (the probe answered) …
expect(await screen.findByText('«marketplace.detail.installedV»')).toBeInTheDocument();
// … and nothing calls the older version an update.
expect(screen.queryByText(/marketplace\.detail\.updateAvailable/)).not.toBeInTheDocument();
expect(screen.queryByText('«marketplace.action.updateTo»')).not.toBeInTheDocument();
expect(screen.getByText('«marketplace.action.installed»')).toBeInTheDocument();
});
});

describe('POSITIVE CONTROL — installed version is OLDER than the latest', () => {
it('still offers the update', async () => {
await bootEnvConsole();
getMarketplacePackage.mockResolvedValue(packageWithLatest('1.1.0'));
getCloudInstallationInfo.mockResolvedValue({ version: '1.0.0' });

render(<MarketplacePackagePage />);

await screen.findByText('Sample');
expect(await screen.findByText(/marketplace\.detail\.updateAvailable/)).toBeInTheDocument();
expect(screen.getByText('«marketplace.action.updateTo»')).toBeInTheDocument();
});
});

describe('CONTROL — installed version EQUALS the latest', () => {
it('offers no update', async () => {
await bootEnvConsole();
getMarketplacePackage.mockResolvedValue(packageWithLatest('1.0.0'));
getCloudInstallationInfo.mockResolvedValue({ version: '1.0.0' });

render(<MarketplacePackagePage />);

await screen.findByText('Sample');
expect(await screen.findByText('«marketplace.detail.installedV»')).toBeInTheDocument();
expect(screen.queryByText(/marketplace\.detail\.updateAvailable/)).not.toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* SemVer 2.0.0 precedence behind the marketplace "update available" answer
* (objectui#10899 item 7). The page-level consequence is pinned in
* `MarketplacePackagePage.updateIsHigherVersion-10899.test.tsx`; this suite
* pins the ordering itself against the spec's own §11 example chain.
*/

import { describe, it, expect } from 'vitest';
import { compareVersionPrecedence, isNewerVersion } from '../versionPrecedence';

describe('compareVersionPrecedence (SemVer 2.0.0 §11)', () => {
it('orders the numeric core numerically, not as text', () => {
expect(compareVersionPrecedence('1.10.0', '1.9.0')).toBe(1);
expect(compareVersionPrecedence('1.0.0', '1.1.0')).toBe(-1);
expect(compareVersionPrecedence('2.0.0', '10.0.0')).toBe(-1);
expect(compareVersionPrecedence('1.2.3', '1.2.3')).toBe(0);
});

it('follows the spec example chain 1.0.0-alpha < … < 1.0.0 end to end', () => {
const chain = [
'1.0.0-alpha',
'1.0.0-alpha.1',
'1.0.0-alpha.beta',
'1.0.0-beta',
'1.0.0-beta.2',
'1.0.0-beta.11',
'1.0.0-rc.1',
'1.0.0',
];
for (let i = 0; i < chain.length - 1; i += 1) {
expect(compareVersionPrecedence(chain[i], chain[i + 1])).toBe(-1);
expect(compareVersionPrecedence(chain[i + 1], chain[i])).toBe(1);
}
});

it('ignores build metadata', () => {
expect(compareVersionPrecedence('1.0.0+20230101', '1.0.0+build.9')).toBe(0);
});

it('answers null for a string that is not a SemVer version', () => {
expect(compareVersionPrecedence('installed', '1.0.0')).toBeNull();
expect(compareVersionPrecedence('1.0', '1.0.0')).toBeNull();
expect(compareVersionPrecedence('v1.0.0', '1.0.0')).toBeNull();
expect(compareVersionPrecedence('01.0.0', '1.0.0')).toBeNull();
});
});

describe('isNewerVersion — the only condition that may offer an update', () => {
it('is true only for a HIGHER version', () => {
expect(isNewerVersion('1.1.0', '1.0.0')).toBe(true);
expect(isNewerVersion('1.0.0', '1.0.0-rc.1')).toBe(true);
});

it('is false for an older version — the reported `v1.1.0 → v1.0.0` case', () => {
expect(isNewerVersion('1.0.0', '1.1.0')).toBe(false);
});

it('is false for an equal or incomparable pair', () => {
expect(isNewerVersion('1.0.0', '1.0.0')).toBe(false);
expect(isNewerVersion('1.0.0', 'installed')).toBe(false);
});
});
Loading
Loading