Skip to content

Commit 601c773

Browse files
fix(app-shell): draw the draft preview failure and empty panes with the shared view states (#26135)
<!-- claude-projects-attribution: {"github_login":"os-steve"} --> _Requested by **Steve** · [project thread](https://claude.ai/code/project/chan_01FdL1XeUyvVNxLreh3F4Qi9?thread=cmsg_01FdL1XeUyvVNxLreh3F4Qi97Yc9Hw7zbkBCZQmMnndL2D)_ Part of #25915 Clause-②: no (one surface swaps its own block for the shared components; no exported type change. If the shared component needs a new prop, the thread reports first and the seat amends this line) Metadata-protocol: none ## What Slice 1 of #25915: one surface moves onto the shared view states. `ui/packages/app-shell/src/preview/PreviewDraftEmptyState.tsx` (the pane a `?preview=draft` tree shows when it has nothing to draw) no longer composes the bare `Empty` primitive: - overlay read failed: `DataErrorState`, so it is `role="alert"`; - app not in the draft yet, or nothing drafted: `DataEmptyState`, so it is `role="status"`. Text (same i18n keys and default values), the Retry control (`preview-draft-retry`, glyph included) and the outer `preview-draft-empty-state` wrapper are unchanged. The title and icon take the shared components' sizes, which is the visual diff. ## Why this surface (measured on main 6d15aca) app-shell defines six empty blocks of its own and imports `DataEmptyState` / `DataErrorState` zero times. Importers per block: `InspectorEmptyState` 7, `StudioEmptyState` 4, `PreviewEmptyState` 3, `AppNoAccessEmptyState` 2, `agentEmptyState` 1, `PreviewDraftEmptyState` 1. `PreviewDraftEmptyState` is the only one of the six that draws a load **failure**, and it drew it as the same unlabelled node as its empty case, so a failed draft-overlay read was announced as nothing. The card's "route renderer catch paths to `DataErrorState`" lands exactly here. It has one importer (`AppContent.tsx`, not edited), is outside `ObjectView.tsx` and seat 2's packages, and no `claude/*` branch on origin touches it (depth-1 blob compare of all 707 heads against main; control file `DraftChangesPanel.tsx` differs on many). Non-`claude/*` branches were not scanned. app-shell is outside the sdui registry closure: `node scripts/gen-sdui-manifest-node.mjs` re-recorded `scripts/sdui-manifest.record.json` byte-identical (922 files in 27 packages), so the record is not in the diff. ## Tests New `ui/packages/app-shell/src/preview/__tests__/PreviewDraftEmptyState.sharedStates-25915.test.tsx`: failure is `role="alert"` with `data-slot="data-error-state"` and no status; not-yet-drafted and nothing-drafted are `role="status"` with `data-slot="data-empty-state"` and no alert; one Retry control in each pane calls `onRetry`. - Run at 250585b: that file plus `ui/packages/i18n/src/__tests__/draftBar-es-register-3844.test.ts` (which names this component), `Test Files 2 passed (2)`, `Tests 15 passed (15)`. - Ablation: with the fix committed, the source reverted to its main version, the new file reads `Tests 3 failed | 2 passed (5)` (the three role tests red, the two Retry tests green as expected); restored with `git checkout HEAD --`, `git diff HEAD` empty and blob hash equal to HEAD. - `pnpm --filter @object-ui/app-shell type-check` (both `tsc --noEmit` and `tsc -p tsconfig.test.json`), after building `@object-ui/app-shell^...`: exit 0. ## Acceptance notes - The rest of app-shell's blocks (`InspectorEmptyState`, `StudioEmptyState`, `PreviewEmptyState`, `AppNoAccessEmptyState`, `agentEmptyState`) stay for later slices; #25915 remains open. - `DataErrorState`'s own `onRetry` button carries no glyph or test id, so this surface passes its existing Retry button as children rather than adding a prop to the shared component. --- _Generated by [Claude Code](https://claude.ai/code/session_01FdXMkfnYedDKghz7QLUkx8)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FdXMkfnYedDKghz7QLUkx8 Co-authored-by: Claude <noreply@anthropic.com>
1 parent a2dfaeb commit 601c773

3 files changed

Lines changed: 123 additions & 29 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
fix(app-shell): the draft preview's failure pane is announced as an alert, drawn by the shared `DataErrorState`
6+
7+
Clause-②: no (one surface swaps its own block for the shared components; no exported type change. If the shared component needs a new prop, the thread reports first and the seat amends this line)
8+
Metadata-protocol: none
9+
10+
The pane a `?preview=draft` tree shows when it has nothing to draw (`PreviewDraftEmptyState`) now uses the shared view states from `@object-ui/components` instead of its own `Empty` block. When the draft overlay read fails, it is `DataErrorState` and is announced to assistive technology as `role="alert"`. When the app is not in the draft yet, or nothing is drafted, it is `DataEmptyState` with `role="status"`. Before, both were the same unlabelled node, so a screen reader said nothing when the preview failed to load. The text, the Retry button and the test ids are unchanged; the title and icon now use the shared components' smaller size.

‎ui/packages/app-shell/src/preview/PreviewDraftEmptyState.tsx‎

Lines changed: 42 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,18 @@
2323
*
2424
* Deliberately CTA-free beyond Retry: the preview is a window owned by the
2525
* build conversation; every corrective action lives there.
26+
*
27+
* The two halves are drawn by the shared vocabulary, not by a block of their
28+
* own: the load failure by `DataErrorState` and the other two by
29+
* `DataEmptyState`. That is what makes them distinguishable to assistive
30+
* technology — the failure is announced as `role="alert"`, the empty pane as
31+
* `role="status"` — where the bare `Empty` primitive this used to compose gave
32+
* both the same unlabelled node, so a failed overlay read was announced as
33+
* nothing at all.
2634
*/
2735

2836
import { Eye, RefreshCw, TriangleAlert } from 'lucide-react';
29-
import { Empty, EmptyTitle, EmptyDescription, Button } from '@object-ui/components';
37+
import { Button, DataEmptyState, DataErrorState } from '@object-ui/components';
3038
import { useObjectTranslation } from '@object-ui/i18n';
3139

3240
export interface PreviewDraftEmptyStateProps {
@@ -40,40 +48,45 @@ export interface PreviewDraftEmptyStateProps {
4048

4149
export function PreviewDraftEmptyState({ appName, error, onRetry }: PreviewDraftEmptyStateProps) {
4250
const { t } = useObjectTranslation();
43-
const failed = Boolean(error);
51+
// Both states offer the same Retry, so it is one node: the shared
52+
// components' own `onRetry` button carries neither the glyph nor the test id
53+
// this control has always had.
54+
const retry = (
55+
<Button variant="outline" onClick={onRetry} data-testid="preview-draft-retry">
56+
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
57+
{t('preview.empty.retry', { defaultValue: 'Retry' })}
58+
</Button>
59+
);
4460
return (
4561
<div className="flex h-screen items-center justify-center" data-testid="preview-draft-empty-state">
46-
<Empty>
47-
<div className="mb-2 flex justify-center text-muted-foreground" aria-hidden="true">
48-
{failed ? <TriangleAlert className="h-8 w-8" /> : <Eye className="h-8 w-8" />}
49-
</div>
50-
<EmptyTitle>
51-
{failed
52-
? t('preview.empty.loadFailedTitle', { defaultValue: 'Draft preview failed to load' })
53-
: appName
62+
{error ? (
63+
<DataErrorState
64+
icon={<TriangleAlert className="size-5 text-destructive" aria-hidden="true" />}
65+
title={t('preview.empty.loadFailedTitle', { defaultValue: 'Draft preview failed to load' })}
66+
message={t('preview.empty.loadFailedDescription', {
67+
defaultValue: 'The draft overlay could not be read. Retry, or check your connection.',
68+
})}
69+
>
70+
{retry}
71+
</DataErrorState>
72+
) : (
73+
<DataEmptyState
74+
icon={<Eye className="size-5 text-muted-foreground" aria-hidden="true" />}
75+
title={
76+
appName
5477
? t('preview.empty.notReadyTitle', {
5578
app: appName,
5679
defaultValue: '“{{app}}” isn’t in the draft yet',
5780
})
58-
: t('preview.empty.nothingTitle', { defaultValue: 'Nothing to preview yet' })}
59-
</EmptyTitle>
60-
<EmptyDescription>
61-
{failed
62-
? t('preview.empty.loadFailedDescription', {
63-
defaultValue: 'The draft overlay could not be read. Retry, or check your connection.',
64-
})
65-
: t('preview.empty.notReadyDescription', {
66-
defaultValue:
67-
'The build may still be running, or it may have failed before this app was staged. Check the conversation for the build status — this pane refreshes as drafts land.',
68-
})}
69-
</EmptyDescription>
70-
<div className="mt-4 flex justify-center">
71-
<Button variant="outline" onClick={onRetry} data-testid="preview-draft-retry">
72-
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
73-
{t('preview.empty.retry', { defaultValue: 'Retry' })}
74-
</Button>
75-
</div>
76-
</Empty>
81+
: t('preview.empty.nothingTitle', { defaultValue: 'Nothing to preview yet' })
82+
}
83+
description={t('preview.empty.notReadyDescription', {
84+
defaultValue:
85+
'The build may still be running, or it may have failed before this app was staged. Check the conversation for the build status — this pane refreshes as drafts land.',
86+
})}
87+
action={retry}
88+
/>
89+
)}
7790
</div>
7891
);
7992
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The draft preview's non-happy pane is drawn by the shared view-state
11+
* vocabulary (objectstack#25915), so a failed overlay read and an app that is
12+
* not drafted yet are two different nodes to assistive technology: the
13+
* failure is the `role="alert"` `DataErrorState`, the empty pane the
14+
* `role="status"` `DataEmptyState`. Before, both were the same unlabelled
15+
* `Empty` block, and the failure was announced as nothing.
16+
*
17+
* The `t` mock returns each call site's `defaultValue`, which the i18n
18+
* call-site gate holds byte-equal to the `en` pack value.
19+
*/
20+
21+
import '@testing-library/jest-dom/vitest';
22+
import { describe, expect, it, vi } from 'vitest';
23+
import { fireEvent, render, screen } from '@testing-library/react';
24+
25+
vi.mock('@object-ui/i18n', async (importOriginal) => ({
26+
...(await importOriginal<typeof import('@object-ui/i18n')>()),
27+
useObjectTranslation: () => ({
28+
t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k,
29+
}),
30+
}));
31+
32+
import { PreviewDraftEmptyState } from '../PreviewDraftEmptyState';
33+
34+
describe('PreviewDraftEmptyState draws the shared view states (objectstack#25915)', () => {
35+
it('announces a failed overlay read as an alert, through DataErrorState', () => {
36+
render(<PreviewDraftEmptyState appName="crm" error={new Error('boom')} onRetry={() => {}} />);
37+
38+
const alert = screen.getByRole('alert');
39+
expect(alert).toHaveAttribute('data-slot', 'data-error-state');
40+
expect(alert).toHaveTextContent('Draft preview failed to load');
41+
expect(screen.queryByRole('status')).toBeNull();
42+
});
43+
44+
it('draws an app not yet drafted as a status, through DataEmptyState', () => {
45+
render(<PreviewDraftEmptyState appName="crm" error={null} onRetry={() => {}} />);
46+
47+
const status = screen.getByRole('status');
48+
expect(status).toHaveAttribute('data-slot', 'data-empty-state');
49+
expect(status).toHaveTextContent('isn’t in the draft yet');
50+
expect(screen.queryByRole('alert')).toBeNull();
51+
});
52+
53+
it('draws nothing drafted (no app in the URL) as a status too', () => {
54+
render(<PreviewDraftEmptyState onRetry={() => {}} />);
55+
56+
expect(screen.getByRole('status')).toHaveTextContent('Nothing to preview yet');
57+
expect(screen.queryByRole('alert')).toBeNull();
58+
});
59+
60+
it.each([
61+
['failed', new Error('boom')],
62+
['empty', null],
63+
])('keeps the one Retry control in the %s pane', (_label, error) => {
64+
const onRetry = vi.fn();
65+
render(<PreviewDraftEmptyState appName="crm" error={error} onRetry={onRetry} />);
66+
67+
expect(screen.getAllByRole('button')).toHaveLength(1);
68+
fireEvent.click(screen.getByTestId('preview-draft-retry'));
69+
expect(onRetry).toHaveBeenCalledTimes(1);
70+
});
71+
});

0 commit comments

Comments
 (0)