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
26 changes: 26 additions & 0 deletions .changeset/11930-workspace-timezone-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
'@object-ui/app-shell': minor
'@object-ui/types': minor
'@object-ui/i18n': minor
'@object-ui/cli': minor
---

New SDUI widget `cloud:workspace-timezone-notice`: one line on the Cloud welcome page naming the timezone a workspace was seeded with at creation (objectui#11930, the objectui half of objectstack-ai/cloud#2676).

**Clause-②: yes** — four published surfaces widen, and nothing that parsed or rendered before changes:

- the accept set of `AnyComponentSchema`, and so of `safeValidateSchema` and `objectui validate`, widens by one `type` literal, `cloud:workspace-timezone-notice`. `@object-ui/types/zod` exports one new schema, `CloudWorkspaceTimezoneNoticeSchema`, and `@object-ui/types` exports its TypeScript twin of the same name, a member of `AnySchema`;
- `@object-ui/i18n` adds one key, `cloudWorkspaceTimezoneNotice.seeded`, to all ten locale packs;
- `@object-ui/cli`: `objectui check` knows `cloud:workspace-timezone-notice` as a registered type;
- `@object-ui/app-shell` registers the widget, and its `sideEffects` array names the new module in its source and published spellings.

**Why.** The welcome page is static metadata, and nothing in a page's expression scope carries a per-organization value, so the page could not say which timezone the workspace was created with. The seed is available only from the org-scoped `GET /cloud/environment-entitlements` summary, as the additive `workspaceTimezoneSeed` string.

**What changed, in observable terms.**

- A page places the node with no props: `{ "type": "cloud:workspace-timezone-notice" }`. The widget reads the summary through the hook the environment list and `cloud:plan-status` already use, and when the summary carries `workspaceTimezoneSeed` it renders one muted line naming that zone, verbatim. In English: "The workspace timezone was set to Asia/Shanghai from your browser when the workspace was created. You can change it in Settings → Localization."
- It renders nothing when the summary carries no seed (workspaces created before the seed existed, and control planes that do not send it yet), while the summary loads, when the request fails or rejects, and when the body is not the `{ success, data }` envelope. A failed request does not throw.
- The line is text only: no link and no dismissal state.
- The node's `className` and `responsiveStyles` reach the line.
- The widget is registered under one key, `cloud:workspace-timezone-notice`. There is no bare `workspace-timezone-notice` fallback and no `app-shell:`-prefixed twin.
- `CloudWorkspaceTimezoneNoticeSchema` takes no prop: `properties` is optional and may only be `{}`, so any key in the bag, the zone included, is refused at `properties`. `body` and `children` are refused by name, because the widget reads neither.
2 changes: 2 additions & 0 deletions packages/app-shell/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"./dist/console/diagnostics/CloudAiModelStatus.js",
"./dist/console/home/CloudOnboardingNext.js",
"./dist/console/home/CloudPlanStatus.js",
"./dist/console/home/CloudWorkspaceTimezoneNotice.js",
"./dist/console/marketplace/InstalledListWidget.js",
"./dist/services/builtinComponents.js",
"./dist/views/global-notifications-renderer.js",
Expand All @@ -26,6 +27,7 @@
"./src/console/diagnostics/CloudAiModelStatus.tsx",
"./src/console/home/CloudOnboardingNext.tsx",
"./src/console/home/CloudPlanStatus.tsx",
"./src/console/home/CloudWorkspaceTimezoneNotice.tsx",
"./src/console/marketplace/InstalledListWidget.tsx",
"./src/services/builtinComponents.tsx",
"./src/views/global-notifications-renderer.tsx",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* CloudWorkspaceTimezoneNotice — one line on the Cloud control plane's welcome
* page naming the timezone a workspace was seeded with at creation, registered
* as the SDUI widget `cloud:workspace-timezone-notice` (objectui#11930, the
* objectui half of objectstack-ai/cloud#2676).
*
* The welcome page is static metadata: nothing in its expression scope carries
* a per-organization value, so a page node cannot print the seeded zone. The
* seed is only available from the org-scoped `GET /cloud/environment-entitlements`
* summary (`data.workspaceTimezoneSeed`, an additive string). This widget reads
* that summary and says one thing about it:
*
* • the summary carries a seed → one muted, localized line naming it;
* • the summary carries no seed → nothing. That covers every workspace
* created before the seed existed and every control plane that does not
* send the field yet;
* • loading, failed, or no summary → nothing. The row-derived fallback of
* `useEnvironmentEntitlements` carries no seed, so a failed summary request
* degrades to the empty render, never to a guess and never to a throw.
*
* The zone is printed VERBATIM — it is an IANA id (`Asia/Shanghai`), not a
* value to localize — and the line is text only: no link (a relative link from
* the control plane would open the control plane's own settings, cloud#2676
* Q2) and no dismissal state.
*
* ## The summary is read through `useEnvironmentEntitlements`
*
* The same hook `cloud:plan-status` and the environment list use: one reading of
* the endpoint and its strict `{ success, data }` envelope, not another copy of
* the fetch.
*/

import { useState } from 'react';
import { cn } from '@object-ui/components';
import { createAuthenticatedFetch } from '@object-ui/auth';
import { ComponentRegistry } from '@object-ui/core';
import { useObjectTranslation } from '@object-ui/i18n';
import { useAdapter } from '../../providers/AdapterProvider.js';
import { useEnvironmentEntitlements } from '../../environment/useEnvironmentEntitlements.js';

export interface CloudWorkspaceTimezoneNoticeProps {
/** Carries the scope class of the node's `responsiveStyles`. */
className?: string;
}

export function CloudWorkspaceTimezoneNotice({ className }: CloudWorkspaceTimezoneNoticeProps) {
const { t } = useObjectTranslation();
const dataSource = useAdapter();
// `useState`, not `useMemo`: the hook keys its fetch effect on this function,
// so its identity has to be one React guarantees to keep (AGENTS.md #10).
const [authFetch] = useState(() => createAuthenticatedFetch());
const entitlements = useEnvironmentEntitlements({
enabled: true,
dataSource,
authFetch,
apiBase: (import.meta.env.VITE_SERVER_URL as string | undefined) || '',
});

const zone = entitlements?.workspaceTimezoneSeed;
if (typeof zone !== 'string' || zone === '') return null;

return (
<p className={cn('text-sm text-muted-foreground', className)} data-workspace-timezone-notice="seeded">
{t('cloudWorkspaceTimezoneNotice.seeded', { zone })}
</p>
);
}

// SDUI registration — the Cloud welcome page places one node. Registered under
// the `cloud` namespace with no bare fallback, so the one key is
// `cloud:workspace-timezone-notice`: the spelling the page authors, and the one
// literal its `@object-ui/types` arm claims (`CloudWorkspaceTimezoneNoticeSchema`).
ComponentRegistry.register('workspace-timezone-notice', (props: CloudWorkspaceTimezoneNoticeProps) => (
<CloudWorkspaceTimezoneNotice {...props} />
), {
namespace: 'cloud',
skipFallback: true,
label: 'Cloud Workspace Timezone Notice',
category: 'plugin',
inputs: [],
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* CloudWorkspaceTimezoneNotice (`cloud:workspace-timezone-notice`,
* objectui#11930) prints ONE line on the Cloud welcome page naming the
* timezone the workspace was seeded with, read from the authoritative
* entitlements summary's `workspaceTimezoneSeed` — and nothing in every other
* state: no seed, a failed request, a body outside the envelope, or loading.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import React from 'react';

let fetchImpl: (url: string, init?: RequestInit) => Promise<unknown>;
const fetchedUrls: string[] = [];

vi.mock('@object-ui/auth', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useAuth: () => ({ activeOrganization: { id: 'org_1' } }),
createAuthenticatedFetch: () => (url: string, init?: RequestInit) => {
fetchedUrls.push(url);
return fetchImpl(url, init);
},
}));

import { I18nProvider } from '@object-ui/i18n';
import { ComponentRegistry } from '@object-ui/core';
import { AdapterCtx, SchemaRenderer } from '@object-ui/react';
import { CloudWorkspaceTimezoneNotice } from '../CloudWorkspaceTimezoneNotice';

const EN = { defaultLanguage: 'en', detectBrowserLanguage: false } as const;
const ZH = { defaultLanguage: 'zh', detectBrowserLanguage: false } as const;

/** The two packs' sentences for the seed below — the copy a user reads. */
const EN_LINE =
'The workspace timezone was set to Asia/Shanghai from your browser when the workspace was created. '
+ 'You can change it in Settings → Localization.';
const ZH_LINE = '工作区时区在创建时已按你的浏览器设为 Asia/Shanghai,可在「设置 → 本地化」中修改。';

/**
* The line resolves from the locale packs, so a real i18n context is needed —
* without one `t()` returns the raw key and a text assertion would pass
* against nothing. The adapter is the row-derived fallback's data source; its
* rows resolve, so a failed summary falls through to a REAL `derived` state.
*/
const adapter = { find: vi.fn().mockResolvedValue([{ id: 'env_1', environment_type: 'production' }]) };

const renderInShell = (ui: React.ReactElement, config: typeof EN | typeof ZH = EN) =>
render(
<I18nProvider config={config} persistLanguage={false}>
<AdapterCtx.Provider value={adapter as never}>{ui}</AdapterCtx.Provider>
</I18nProvider>,
);

/** The control plane's `{ success, data }` envelope around a summary. */
function summary(data: Record<string, unknown>) {
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
}

const SEEDED = { plan: 'free', hasProductionEnv: true, workspaceTimezoneSeed: 'Asia/Shanghai' };
const UNSEEDED = { plan: 'free', hasProductionEnv: true };

const noticeOf = (container: HTMLElement) => container.querySelector('[data-workspace-timezone-notice]');

/** Waits until the summary was read and the hook settled, so an empty render is a verdict, not the loading state. */
async function settledAfterSummary() {
await waitFor(() => expect(fetchedUrls).toHaveLength(1));
await new Promise((r) => setTimeout(r, 0));
}

afterEach(() => {
cleanup();
fetchedUrls.length = 0;
adapter.find.mockClear();
});

describe('CloudWorkspaceTimezoneNotice — the seed is present (objectui#11930)', () => {
it('names the seeded zone in English, verbatim', async () => {
fetchImpl = async () => summary(SEEDED);
const { container } = renderInShell(<CloudWorkspaceTimezoneNotice />);

expect(await screen.findByText(EN_LINE)).toBeTruthy();
expect(noticeOf(container)?.textContent).toBe(EN_LINE);
});

it('names the seeded zone in Chinese, verbatim', async () => {
fetchImpl = async () => summary(SEEDED);
const { container } = renderInShell(<CloudWorkspaceTimezoneNotice />, ZH);

expect(await screen.findByText(ZH_LINE)).toBeTruthy();
expect(noticeOf(container)?.textContent).toBe(ZH_LINE);
});

it('reads the org-scoped summary endpoint', async () => {
fetchImpl = async () => summary(SEEDED);
renderInShell(<CloudWorkspaceTimezoneNotice />);

await screen.findByText(EN_LINE);
expect(fetchedUrls).toEqual(['/api/v1/cloud/environment-entitlements?organizationId=org_1']);
});

it('is one line of text — no link, no button, no dismissal', async () => {
fetchImpl = async () => summary(SEEDED);
const { container } = renderInShell(<CloudWorkspaceTimezoneNotice />);

await screen.findByText(EN_LINE);
expect(container.querySelectorAll('a, button')).toHaveLength(0);
expect(noticeOf(container)?.children).toHaveLength(0);
});
});

describe('CloudWorkspaceTimezoneNotice — nothing renders without a seed (objectui#11930)', () => {
it('renders nothing when the summary carries no seed — the control', async () => {
fetchImpl = async () => summary(UNSEEDED);
const { container } = renderInShell(<CloudWorkspaceTimezoneNotice />);

await settledAfterSummary();
expect(container.innerHTML).toBe('');
});

it('renders nothing for an empty seed', async () => {
fetchImpl = async () => summary({ ...UNSEEDED, workspaceTimezoneSeed: '' });
const { container } = renderInShell(<CloudWorkspaceTimezoneNotice />);

await settledAfterSummary();
expect(container.innerHTML).toBe('');
});

it('renders nothing while the summary is loading', () => {
fetchImpl = () => new Promise(() => {});
const { container } = renderInShell(<CloudWorkspaceTimezoneNotice />);

expect(container.innerHTML).toBe('');
});

it('renders nothing, and does not throw, when the summary request fails', async () => {
fetchImpl = async () => ({ ok: false, status: 500, json: async () => null });
const { container } = renderInShell(<CloudWorkspaceTimezoneNotice />);

// The hook fell through to the rows, which resolve (source `derived`) but
// carry no seed — so the line is left out instead of guessed.
await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1));
await new Promise((r) => setTimeout(r, 0));
expect(container.innerHTML).toBe('');
});

it('renders nothing, and does not throw, when the summary request rejects', async () => {
fetchImpl = async () => {
throw new TypeError('Failed to fetch');
};
const { container } = renderInShell(<CloudWorkspaceTimezoneNotice />);

await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1));
await new Promise((r) => setTimeout(r, 0));
expect(container.innerHTML).toBe('');
});

it('renders nothing for a bare (un-enveloped) body that carries a seed', async () => {
// A bare body is a producer contract violation (cloud#1046): it yields no
// summary, so even a seed in it must not print.
fetchImpl = async () => ({ ok: true, status: 200, json: async () => SEEDED });
const { container } = renderInShell(<CloudWorkspaceTimezoneNotice />);

await waitFor(() => expect(adapter.find).toHaveBeenCalledTimes(1));
await new Promise((r) => setTimeout(r, 0));
expect(container.innerHTML).toBe('');
});
});

describe('`cloud:workspace-timezone-notice` registration (objectui#11930)', () => {
it('is registered under the one key the page authors, with no second spelling', () => {
expect(ComponentRegistry.has('cloud:workspace-timezone-notice')).toBe(true);
// `skipFallback: true` — no bare `workspace-timezone-notice` fallback …
expect(ComponentRegistry.has('workspace-timezone-notice')).toBe(false);
// … and no `app-shell:`-prefixed twin.
expect(ComponentRegistry.has('app-shell:cloud:workspace-timezone-notice')).toBe(false);
});

it('renders from a page node through SchemaRenderer', async () => {
fetchImpl = async () => summary(SEEDED);
const { container } = renderInShell(
<SchemaRenderer
schema={{
type: 'cloud:workspace-timezone-notice',
id: 'welcome_timezone_notice',
responsiveStyles: { large: { marginTop: '8px' } },
} as never}
/>,
);

expect(await screen.findByText(EN_LINE)).toBeTruthy();
// The node's `responsiveStyles` scope class reaches the line through
// `className`, so the page can place it.
const scope = container.querySelector('style[data-os-scope]')?.getAttribute('data-os-scope');
expect(scope).toBeTruthy();
expect(noticeOf(container)?.classList.contains(scope as string)).toBe(true);
});
});
14 changes: 14 additions & 0 deletions packages/app-shell/src/environment/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,13 @@ export interface EnvironmentEntitlementsSummary {
seatCount?: number;
upgradeUrl?: string;
contactSalesUrl?: string;
/**
* The IANA timezone the workspace was seeded with at creation, from the
* creator's browser (objectstack-ai/cloud#2676). Additive: absent when no
* seed was recorded, which includes every workspace created before the seed
* existed and every control plane that does not send it yet.
*/
workspaceTimezoneSeed?: string;
}

/** Combined client state (authoritative summary, or a row-derived fallback). */
Expand All @@ -206,6 +213,13 @@ export interface EnvironmentEntitlementsState {
*/
upgradeUrl?: string;
contactSalesUrl?: string;
/**
* The summary's `workspaceTimezoneSeed`, verbatim. Absent when the control
* plane sent none — including the row-derived and unknown states, which
* never had one — and then the `cloud:workspace-timezone-notice` widget
* renders nothing (objectui#11930).
*/
workspaceTimezoneSeed?: string;
/** Where the signal came from — telemetry + degradation note + tests. */
source: 'summary' | 'derived' | 'unknown';
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,9 @@ export function useEnvironmentEntitlements(
// upgrade prompt then renders no CTA (objectui#10437).
upgradeUrl: data.upgradeUrl,
contactSalesUrl: data.contactSalesUrl,
// Verbatim, like `upgradeUrl`: absent stays absent, and the
// workspace-timezone notice then renders nothing (objectui#11930).
workspaceTimezoneSeed: data.workspaceTimezoneSeed,
source: 'summary',
};
} catch {
Expand Down
2 changes: 2 additions & 0 deletions packages/app-shell/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,8 @@ import './console/connect/ConnectAgentWidget.js';
import './console/home/CloudOnboardingNext.js';
// SDUI widget for the Cloud pricing page's "current plan" marker (objectui#10919).
import './console/home/CloudPlanStatus.js';
// SDUI widget for the Cloud welcome page's seeded workspace-timezone line (objectui#11930).
import './console/home/CloudWorkspaceTimezoneNotice.js';
// SDUI widget: read-only admin diagnostic for the env's effective AI model
// (cloud#797) — fetches GET /api/v1/ai/effective-model.
import './console/diagnostics/CloudAiModelStatus.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,14 @@ describe('registered NAMESPACED component types refused at `type` — a ratchet
expect(BARE_KEYS).not.toContain('plan-status');
expect(refusedAtType('cloud:plan-status')).toBe(false);
});

it('counts `cloud:workspace-timezone-notice` armed — it registered WITH its arm (objectui#11930)', () => {
// One registry key (`skipFallback: true`, so no bare `workspace-timezone-notice`),
// armed in `@object-ui/types/zod` in the same change, so the pin above did not move.
expect(NAMESPACED_KEYS).toContain('cloud:workspace-timezone-notice');
expect(BARE_KEYS).not.toContain('workspace-timezone-notice');
expect(refusedAtType('cloud:workspace-timezone-notice')).toBe(false);
});
});

/* ── End to end: the README document through `objectui validate` ─────────── */
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/utils/known-schema-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export const KNOWN_SCHEMA_TYPES: readonly string[] = [
'cloud:ai-model-status',
'cloud:onboarding-next',
'cloud:plan-status',
'cloud:workspace-timezone-notice',
'code',
'code-editor',
'collapsible',
Expand Down
Loading
Loading