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
10 changes: 10 additions & 0 deletions .changeset/12602-home-my-work-i18n.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@object-ui/i18n': minor
---

Locale keys for the console home's "My work" section (objectui#12602), in all ten packs.

- **Added:** `home.myWork.title`, `home.myWork.openInbox`, `home.myWork.viewAll`, `home.myWork.approvals`, `home.myWork.approvalsEmpty`, `home.myWork.notifications` and `home.myWork.notificationsEmpty`. For the pending-drafts row: `home.pendingDrafts.title` (a count family), `home.pendingDrafts.inPackage`, `home.pendingDrafts.waiting` and `home.pendingDrafts.nameSeparator`.
- **Removed:** `home.actionCenter.title`, `home.actionCenter.empty` and the `home.pendingDrafts.message` count family. Their only readers were the retired action-centre card and the retired drafts banner, and nothing in this repository reads them now.

**Clause-②: yes.** The exported `en` pack is typed by its literal content, so its type gains the keys above and loses the removed ones.
14 changes: 14 additions & 0 deletions .changeset/12602-home-my-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/app-shell': minor
---

The console home's "Needs your attention" card becomes the "My work" section of the approved home artboard (objectui#12602), and the pending-drafts banner above the greeting moves into it.

- **Heading.** "My work", 15 px / 600, with a count pill: the approvals waiting on the user plus the unread notification topics, the two numbers the bell adds up for its badge. At the right, an "Open inbox" link opens the inbox page the bell's "view all" opens.
- **Two columns.** Approvals first, then Notifications, side by side from `md` up with a 12 px gap. Each column is an 8 px-radius card with a 40 px header (title, count pill, "View all") and up to three 40 px rows: a 24 px icon tile, a one-line title cut with an ellipsis, and a 12 px relative time.
- **Approvals** lists the approval requests waiting on the user, titled by the flow's label and the record's name as the approvals service serves them. Its pill is on the primary colour and is the number of those requests, from the same pending-approvals read the bell and the greeting's summary line use. A row opens that request in the Approvals inbox; "View all" opens the Approvals inbox.
- **Notifications** lists the unread inbox messages, newest first, one per title. Its pill is the unread topic count the bell shows. A row follows its link through the resolver the bell uses; "View all" opens the inbox.
- A column with nothing to list says so in one muted line ("Nothing waiting for your approval", "No unread notifications"), only once its read has answered. While a read is loading or after it fails, the column says that instead, beside any rows it still shows.
- **Not three columns.** The artboard splits notifications into "Assigned to me" and "Reminders", and marks overdue items. An inbox message carries a free-text topic and a severity, but no declared kind and no due date, so the section shows one Notifications column and no overdue marking, rather than guessing a kind from the topic or the title.
- **Who sees it.** A column is drawn only when the user may read its source. The Notifications column, and the "Open inbox" link, are left out when the loaded permissions refuse read on `sys_inbox_message`; permissions that have not loaded count as readable. The Approvals column needs a signed-in user. With neither source and no drafts to publish, the section is not drawn at all.
- **Pending drafts.** The full-width blue banner above the greeting is removed. Its replacement is a 40 px row under the "My work" heading, on the soft primary tint: a rocket icon, "N unpublished change(s)", the first three draft names (each draft's own label, or its name when it has none) and their package when they all share one, and a 28 px "Publish" button. The button runs the same publish the banner ran. When the workspace has no app yet, the row is drawn at the top of the empty state, where the banner was.
174 changes: 96 additions & 78 deletions packages/app-shell/src/console/home/HomePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import { useCanAuthorMetadata } from '../../hooks/useCanAuthorMetadata.js';
import { HomeAppsStrip } from './HomeAppsStrip.js';
import { lastOpenedByApp } from './appLastOpened.js';
import { RecoveryPasswordReminder } from './RecoveryPasswordReminder.js';
import { HomeActionCenter, HomeContinue, HomeActivity } from './HomeRail.js';
import { HomeActionCenter, HomeContinue, HomeActivity, HomePendingDraftsRow } from './HomeRail.js';
import { resolveI18nLabel } from '@objectstack/spec/ui';
import { useHomeInbox } from '../../hooks/useHomeInbox.js';
import { useNavigationContext } from '../../context/NavigationContext.js';
import {
Expand All @@ -52,7 +53,7 @@ import {
TooltipTrigger,
cn,
} from '@object-ui/components';
import { Sparkles, UploadCloud, MessageSquareText, Hammer, LayoutGrid } from 'lucide-react';
import { Sparkles, MessageSquareText, Hammer, LayoutGrid } from 'lucide-react';
import { usePendingDrafts } from '../../preview/usePendingDrafts.js';
import { usePublishAllDrafts } from '../../preview/usePublishAllDrafts.js';
import { resolveAiApiBase } from '../../hooks/useAiSurface.js';
Expand Down Expand Up @@ -234,54 +235,50 @@ function pickGreetingKey(hour: number): string {
return 'home.greetingNight';
}

/** Draft names the drafts row lists before the rest go uncounted by name. */
const DRAFT_NAMES_SHOWN = 3;

/**
* Pending-drafts banner — closes the AI magic-moment loop. After the metadata
* assistant drafts objects/views/apps (ADR-0033 draft-gated authoring), nothing
* is live until the human publishes. Without this, a user who just had AI build
* their whole system landed back on an empty-looking home with no trace of it
* and no path to publish. This surfaces the pending drafts and routes to the
* designer to review + publish. Disappears automatically once published
* (listDrafts → 0).
* The pending drafts, read for Home's drafts row (objectui#12602) — the read
* and the publish the retired banner above the greeting ran, unchanged. After
* the metadata assistant drafts objects, views or apps (ADR-0033 draft-gated
* authoring), nothing is live until a person publishes; the row surfaces them
* with a one-click publish and disappears once they are live (`_drafts` → 0).
*
* `usePendingDrafts({})` is the env-wide scope (objectui#5801): its bus
* subscription keeps the count current after later edits and after a
* publish. `usePublishAllDrafts` is the shared one-click publish, also run by
* the ADR-0037 draft-preview bar. Returns the row, or `null` with nothing to
* publish.
*/
function PendingDraftsBanner({ t }: { t: (key: string, opts?: any) => string }) {
// Shared one-click publish (also used by the ADR-0037 draft-preview bar):
// packages via the probed publish-drafts path, orphans by reference, health
// surfaced in toasts. See usePublishAllDrafts for the full story.
function usePendingDraftsRow(t: (key: string, opts?: any) => string, language: string): React.ReactNode {
const { publishAll, publishing } = usePublishAllDrafts(t);
// objectui#5801 — env-wide scope, from the shared pending-drafts source.
// The bus subscription inside the hook replaces both the mount-only fetch
// (which never saw later edits) and the post-publish window.location.reload
// (publishAll now emits the metadata-refresh pulse; the launcher/nav refresh
// through MetadataProvider's subscription to the same pulse).
const { count } = usePendingDrafts({});

const publish = async () => {
await publishAll();
};

const { count, entries } = usePendingDrafts({});
if ((count ?? 0) <= 0) return null;
// The draft's own label in the console language (objectui#11862), its
// name only where `_drafts` served none.
const names = entries
.slice(0, DRAFT_NAMES_SHOWN)
.map((e) => resolveI18nLabel(e.label, language)?.trim() || e.name);
// Named only when every draft is in one package: a row naming one package
// for drafts spread over several would misplace the rest.
const packages = new Set(entries.map((e) => e.packageId));
const [onlyPackage] = packages;
return (
<div className="px-4 sm:px-6 lg:px-8 pt-4">
<div className="max-w-7xl mx-auto">
<div className="flex items-center gap-3 rounded-xl border border-indigo-300/60 dark:border-indigo-700/50 bg-indigo-50 dark:bg-indigo-950/30 px-4 py-3">
<UploadCloud className="h-5 w-5 shrink-0 text-indigo-600 dark:text-indigo-400" />
<p className="flex-1 min-w-0 text-sm text-indigo-900 dark:text-indigo-200">
{t('home.pendingDrafts.message', { count, defaultValue: 'You have {{count}} unpublished change(s) — publish to make them live.' })}
</p>
<Button size="sm" onClick={publish} disabled={publishing} data-testid="pending-drafts-publish">
{publishing
? t('home.pendingDrafts.publishing', { defaultValue: 'Publishing…' })
: t('home.pendingDrafts.cta', { defaultValue: 'Publish' })}
</Button>
</div>
</div>
</div>
<HomePendingDraftsRow
count={count ?? 0}
names={names}
packageName={packages.size === 1 && onlyPackage ? onlyPackage : undefined}
publishing={publishing}
onPublish={() => void publishAll()}
t={t}
/>
);
}

export function HomePage() {
const navigate = useNavigate();
const { t } = useObjectTranslation();
const { t, language } = useObjectTranslation();
const { apps, loading } = useMetadata();
const { recentItems } = useRecentItems();
// A recent entry stores identity; the rail labels it with this, on the
Expand All @@ -292,12 +289,17 @@ export function HomePage() {
const { isAdmin, isResolved: isAdminResolved } = useWorkspaceAdminStatus();
const {
pendingApprovalsCount,
pendingApprovals,
pendingApprovalsReadable,
pendingApprovalsStatus,
notifications,
unreadTopicCount,
notificationsStatus,
notificationsReadable,
activity,
} = useHomeInbox();
// The drafts row of the My-work section — `null` with nothing to publish.
const draftsRow = usePendingDraftsRow(t, language);
// Home renders OUTSIDE the `/apps/:appName/*` router, so there is no
// `params.appName` to read — `currentAppName` (published by ConsoleLayout on
// every app mount) is the only "which app is the user in" signal available
Expand Down Expand Up @@ -437,7 +439,13 @@ export function HomePage() {
if (activeApps.length === 0) {
return (
<div className="flex flex-col flex-1">
<PendingDraftsBanner t={t} />
{/* With no app yet, the drafts row is the whole of "My work": the
moment the assistant has drafted a first app and nothing is live. */}
{draftsRow && (
<div className="px-4 sm:px-6 lg:px-8 pt-4">
<div className="mx-auto max-w-[1120px]">{draftsRow}</div>
</div>
)}
<RecoveryPasswordReminder t={t} />
<div className="flex flex-1 items-center justify-center p-6">
{/*
Expand Down Expand Up @@ -523,7 +531,6 @@ export function HomePage() {

return (
<div className="relative min-h-full bg-background">
<PendingDraftsBanner t={t} />
<RecoveryPasswordReminder t={t} />

{/* The content column (objectui#12601): 1120 px, centred, 28 px above the
Expand Down Expand Up @@ -670,42 +677,53 @@ export function HomePage() {
marketplaceEnabled={marketplaceEnabled}
/>

{/* Action center — what needs the user; leads the dashboard */}
<div>
<HomeActionCenter
pendingApprovalsCount={pendingApprovalsCount}
notifications={notifications}
/* The badge's total — every unread topic, not the capped list
(#4329); the same number the bell above shows. */
unreadTopicCount={unreadTopicCount}
notificationsStatus={notificationsStatus}
onOpenApprovals={() => navigate(`/apps/${hostAppSegment}/system/approvals`)}
/* `action_url` is APP-RELATIVE (`/{object}/{id}`, ADR-0030 L5),
so it has to be hosted under an app before it is a route —
navigating it verbatim is objectui#5179, where the console
catch-all forwarded the unmatched path to `/` and the landing
resolver dropped the user on the default app's home with the
notification already marked read. `resolveNotificationTarget`
is shared with the top-bar bell so the two cannot answer this
differently. The fallback arm runs whenever a notification
carries no link at all; `?view=mine` selects the user-scoped
view, so the full page matches what the card showed
(objectui#4074). */
onOpenNotification={(n) => {
const target = resolveNotificationTarget(n.actionUrl, hostAppSegment);
if (!target) {
navigate(`/apps/${hostAppSegment}/sys_inbox_message?view=mine`);
return;
}
if (target.kind === 'external') {
window.open(target.url, '_blank', 'noopener,noreferrer');
return;
}
navigate(target.path);
}}
t={t}
/>
</div>
{/* My work (objectui#12602) — approvals, then unread notifications,
with the pending-drafts row under its heading. */}
<HomeActionCenter
pendingApprovals={pendingApprovals}
pendingApprovalsCount={pendingApprovalsCount}
pendingApprovalsStatus={pendingApprovalsStatus}
pendingApprovalsReadable={pendingApprovalsReadable}
notifications={notifications}
/* The pill's total — every unread topic, not the capped list
(#4329); the same number the bell above shows. */
unreadTopicCount={unreadTopicCount}
notificationsStatus={notificationsStatus}
notificationsReadable={notificationsReadable}
draftsRow={draftsRow}
onOpenApprovals={() => navigate(`/apps/${hostAppSegment}/system/approvals`)}
/* The Approvals inbox opens a request's drawer from `?request=`
(#2678), the link its notifications already carry. */
onOpenApproval={(r) =>
navigate(`/apps/${hostAppSegment}/system/approvals?request=${encodeURIComponent(r.id)}`)
}
/* The bell's own "view all" target (`InboxPopover`): `?view=mine`
selects the user-scoped view, so the full page matches what the
column showed (objectui#4074). */
onOpenInbox={() => navigate(`/apps/${hostAppSegment}/sys_inbox_message?view=mine`)}
/* `action_url` is APP-RELATIVE (`/{object}/{id}`, ADR-0030 L5),
so it has to be hosted under an app before it is a route —
navigating it verbatim is objectui#5179, where the console
catch-all forwarded the unmatched path to `/` and the landing
resolver dropped the user on the default app's home with the
notification already marked read. `resolveNotificationTarget`
is shared with the top-bar bell so the two cannot answer this
differently. The fallback arm runs whenever a notification
carries no link at all, and opens the inbox. */
onOpenNotification={(n) => {
const target = resolveNotificationTarget(n.actionUrl, hostAppSegment);
if (!target) {
navigate(`/apps/${hostAppSegment}/sys_inbox_message?view=mine`);
return;
}
if (target.kind === 'external') {
window.open(target.url, '_blank', 'noopener,noreferrer');
return;
}
navigate(target.path);
}}
t={t}
/>

{/* Continue where you left off + ambient activity. The activity column
exists only for a caller who may read `sys_activity`
Expand Down
Loading
Loading