Skip to content
Closed
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
1 change: 1 addition & 0 deletions apps/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3572,6 +3572,7 @@ function AppInner() {
defaultDesignSystemId={config.designSystemId}
agents={agents}
agentsLoading={agentsLoading}
amrLoggedIn={amrLoginStatus?.loggedIn ?? null}
config={config}
providerModelsCache={providerModelsCache}
onProviderModelsCacheChange={setProviderModelsCache}
Expand Down
30 changes: 19 additions & 11 deletions apps/web/src/components/EntryShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import { BrandsTab } from './BrandsTab';
import { EntryNavRail, type EntryView as EntryViewKind } from './EntryNavRail';
import { ProjectSearchModal } from './ProjectSearchModal';
import { CloudSignInTip, RailAccountSyncTip } from './CloudSignInTip';
import { resolveEntryRailAccountFooterState } from './entry-rail-account-state';
import { LibrarySection } from './LibrarySection';
import { UpdaterPopup } from './UpdaterPopup';
import { WhatsNewPopup } from './WhatsNewPopup';
Expand Down Expand Up @@ -416,6 +417,10 @@ interface Props {
// uses this to show the AMR cloud card in a detecting/skeleton state
// instead of hiding it during the seconds AMR's probe takes to settle.
agentsLoading?: boolean;
// Local credential state is independent from the remote workspace read.
// During a transient Cloud outage it prevents the rail from presenting a
// still-signed-in user as signed out.
amrLoggedIn?: boolean | null;
daemonLive: boolean;
onModeChange: (mode: ExecMode) => void;
onAgentChange: (id: string) => void;
Expand Down Expand Up @@ -540,6 +545,7 @@ export function EntryShell({
onProviderModelsCacheChange,
agents,
agentsLoading = false,
amrLoggedIn = null,
daemonLive,
onModeChange,
onAgentChange,
Expand Down Expand Up @@ -586,6 +592,10 @@ export function EntryShell({
// unresolved or unavailable authority into an anonymous, unbound create.
const workspaceContextState = useWorkspaceContext();
const { context: workspaceContext, loading: workspaceLoading } = workspaceContextState;
const accountFooterState = resolveEntryRailAccountFooterState(
workspaceContextState,
amrLoggedIn,
);
const workspaceContextRef = useRef(workspaceContext);
workspaceContextRef.current = workspaceContext;
const workspaceBillingResponse = useWorkspaceBillingResponse();
Expand Down Expand Up @@ -1405,18 +1415,16 @@ export function EntryShell({
onInvite={() => changeView('members')}
onSignInCloud={() => navigate({ kind: 'home', view: 'onboarding' })}
updaterSlot={updaterSlot}
// recvqgpXSYFNTq: `workspaceLoading` is only ever true while
// `workspaceContext` is null (see `useWorkspaceContext`'s
// `markLoading`, which promotes "no context" to "loading" and never
// touches an already-resolved context) — so this is the exact
// window between a just-finished sign-in and the re-read landing.
// Swap the callout for a same-slot loading state there instead of
// rendering nothing, which used to read as the rail silently
// forgetting the user just signed in.
// A loading or unavailable workspace read is not proof of sign-out.
// Keep the account slot neutral until Cloud answers successfully;
// only a successful null context (or known local sign-out) may show
// the sign-in card.
footerNotice={
!workspaceContext ? (
workspaceLoading ? <RailAccountSyncTip /> : <CloudSignInTip />
) : null
accountFooterState === 'syncing'
? <RailAccountSyncTip />
: accountFooterState === 'sign-in'
? <CloudSignInTip />
: null
}
/>
{projectSearchOpen ? (
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/components/EntryView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ interface Props {
// Forwarded to EntryShell → OnboardingView so the AMR cloud card can show a
// detecting/skeleton state while the cold-start agent stream is in flight.
agentsLoading?: boolean;
amrLoggedIn?: boolean | null;
// Execution / model-switching context forwarded to the EntryShell so the
// sticky top-bar can expose the active CLI/BYOK + model and persist
// changes through the same channels as the project view.
Expand Down Expand Up @@ -247,6 +248,7 @@ export function EntryView({
defaultDesignSystemId,
agents,
agentsLoading,
amrLoggedIn,
config,
providerModelsCache,
onProviderModelsCacheChange,
Expand Down Expand Up @@ -374,6 +376,7 @@ export function EntryView({
onProviderModelsCacheChange={onProviderModelsCacheChange}
agents={agents}
{...(agentsLoading !== undefined ? { agentsLoading } : {})}
{...(amrLoggedIn !== undefined ? { amrLoggedIn } : {})}
daemonLive={daemonLive}
onModeChange={onModeChange}
onAgentChange={onAgentChange}
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/FileViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12553,6 +12553,10 @@ function HtmlViewer({
const myMemberId = collab.member?.memberId ?? null;
const iAmProjectOwner = collab.isOwner;
const commentAuthoredByMe = (comment: PreviewComment | null | undefined): boolean => {
// No persisted comment means this is the create flow: the draft belongs
// to the current viewer, including a read-only member/admin annotating
// someone else's shared project.
if (!comment) return true;
const authorId = comment?.authorMemberId ?? null;
// A legacy shared comment without an author is deliberately owner-only.
// Treating it as "mine" for every member made the client advertise a
Expand Down
28 changes: 28 additions & 0 deletions apps/web/src/components/entry-rail-account-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { WorkspaceContextState } from '../collab/useWorkspaceContext';

export type EntryRailAccountFooterState = 'hidden' | 'syncing' | 'sign-in';

/**
* Decide what the rail may claim about the Cloud account.
*
* A successful workspace response with `context: null` is authoritative:
* Cloud is reachable and says there is no active workspace identity, so the
* sign-in entry belongs on screen. A transient outage is not an identity
* answer. While Cloud is unreachable, keep the last resolved workspace (the
* hook does this when one exists) or show the neutral syncing placeholder for
* a locally signed-in/unknown account instead of falsely claiming sign-out.
*/
export function resolveEntryRailAccountFooterState(
workspaceState: WorkspaceContextState,
amrLoggedIn: boolean | null | undefined,
): EntryRailAccountFooterState {
if (workspaceState.context) return 'hidden';
if (workspaceState.loading) return 'syncing';
if (
workspaceState.failure === 'unavailable'
&& amrLoggedIn !== false
) {
return 'syncing';
}
return 'sign-in';
}
68 changes: 68 additions & 0 deletions apps/web/tests/components/EntryShell.amr-workspace-race.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,74 @@ describe('EntryShell AMR workspace precheck race', () => {
resetTeamProjectsCache();
});

it('keeps a locally signed-in account in syncing state while Cloud is unavailable', async () => {
window.history.replaceState(null, '', '/');
const contextFailure = deferred<Response>();
let contextReads = 0;
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith('/api/workspace/context')) {
contextReads += 1;
return contextFailure.promise;
}
if (url.endsWith('/api/plugins')) return jsonResponse({ plugins: [] });
if (url.endsWith('/api/mcp/servers')) return jsonResponse({ servers: [] });
if (url.endsWith('/api/community/discord')) return jsonResponse({ stale: true });
if (url.endsWith('/api/github/open-design')) return jsonResponse({ stale: true });
return jsonResponse({});
}) as typeof fetch;

render(
<I18nProvider initial="en">
<EntryShell
skills={[]}
designTemplates={[]}
designSystems={[]}
projects={[]}
templates={[]}
promptTemplates={[]}
defaultDesignSystemId={null}
connectors={[]}
connectorsLoading={false}
config={amrConfig()}
agents={[amrAgent()]}
amrLoggedIn
daemonLive
onModeChange={vi.fn()}
onAgentChange={vi.fn()}
onAgentModelChange={vi.fn()}
onApiProtocolChange={vi.fn()}
onApiModelChange={vi.fn()}
onConfigPersist={vi.fn()}
onRefreshAgents={vi.fn(() => [amrAgent()])}
onCreateProject={vi.fn()}
onCreatePluginShareProject={vi.fn()}
onImportClaudeDesign={vi.fn()}
onOpenProject={vi.fn()}
onOpenLiveArtifact={vi.fn()}
onDeleteProject={vi.fn()}
onRenameProject={vi.fn()}
onChangeDefaultDesignSystem={vi.fn()}
onPersistComposioKey={vi.fn()}
onOpenSettings={vi.fn()}
onCompleteOnboarding={vi.fn()}
/>
</I18nProvider>,
);

expect(await screen.findByTestId('entry-rail-account-sync-tip')).toBeTruthy();
expect(contextReads).toBe(1);

await act(async () => {
contextFailure.resolve(new Response(null, { status: 503 }));
await contextFailure.promise;
await Promise.resolve();
});

expect(screen.queryByTestId('entry-cloud-signin-tip')).toBeNull();
expect(screen.getByTestId('entry-rail-account-sync-tip')).toBeTruthy();
});

it('rechecks workspace B when the workspace switches after workspace A passes the gate', async () => {
window.history.replaceState(null, '', '/');
const workspaceA = teamContext('workspace-a', 'member-a');
Expand Down
56 changes: 56 additions & 0 deletions apps/web/tests/components/FileViewer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { emptyManualEditStyles } from '../../src/edit-mode/types';
import { __resetPreviewIsolationCache } from '../../src/runtime/powered-preview';
import { readExpandedIndexCss } from '../helpers/read-expanded-css';
import { resetWorkspaceContextCache } from '../../src/collab/useWorkspaceContext';
import { CollabProvider, type CollabContextValue } from '../../src/collab/collab-context';
import {
buildWorkspacePermissions,
buildWorkspaceSeatSummary,
Expand Down Expand Up @@ -5984,6 +5985,61 @@ describe('FileViewer tweaks toolbar', () => {
});
});

it('keeps the Comment CTA for a new element annotation in a viewer-only team project', async () => {
const collab: CollabContextValue = {
enabled: true,
member: { memberId: 'wm-1', name: 'Member', role: 'member' },
present: [],
publishedVersion: 1,
syncState: 'synced',
viewerOnly: true,
isOwner: false,
ownerDisplayName: 'Owner',
ownerRole: 'owner',
downloadPending: false,
reportChange: () => {},
requestPublish: () => {},
refreshPresence: () => {},
checkStatusNow: () => {},
};

render(
<CollabProvider value={collab}>
<FileViewer
projectId="project-1"
projectKind="prototype"
file={htmlPreviewFile()}
liveHtml='<html><body><main data-od-id="hero">Hero</main></body></html>'
viewerOnly
onSavePreviewComment={vi.fn()}
/>
</CollabProvider>,
);

clickAgentTool('board-mode-toggle');
const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement;
window.dispatchEvent(new MessageEvent('message', {
source: frame.contentWindow,
data: {
type: 'od:comment-target',
elementId: 'hero',
selector: '[data-od-id="hero"]',
label: 'Hero',
text: 'Hero',
position: { x: 8, y: 12, width: 120, height: 48 },
hoverPoint: { x: 12, y: 16 },
htmlHint: '<main data-od-id="hero">Hero</main>',
},
}));

const input = await screen.findByTestId('comment-popover-input');
fireEvent.change(input, { target: { value: 'Please tighten this heading.' } });

expect(input).not.toHaveAttribute('readonly');
expect(screen.getByTestId('comment-popover-save')).toHaveTextContent('Comment');
expect(screen.queryByTestId('comment-add-send')).toBeNull();
});

it('docks the comment side panel outside the clickable preview canvas', () => {
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect')
.mockImplementation(function getBoundingClientRectMock(this: HTMLElement) {
Expand Down
61 changes: 61 additions & 0 deletions apps/web/tests/components/entry-rail-account-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';

import {
resolveEntryRailAccountFooterState,
} from '../../src/components/entry-rail-account-state';
import type { WorkspaceContextState } from '../../src/collab/useWorkspaceContext';

const SIGNED_IN_CONTEXT = {
workspaceId: 'workspace-1',
} as WorkspaceContextState['context'];

describe('resolveEntryRailAccountFooterState', () => {
it('keeps the resolved account row when a workspace context exists', () => {
expect(resolveEntryRailAccountFooterState({
context: SIGNED_IN_CONTEXT,
loading: false,
failure: 'unavailable',
}, true)).toBe('hidden');
});

it('shows the neutral syncing state while the workspace identity is loading', () => {
expect(resolveEntryRailAccountFooterState({
context: null,
loading: true,
}, null)).toBe('syncing');
});

it.each([true, null] as const)(
'does not claim sign-out during an outage when local login is %s',
(amrLoggedIn) => {
expect(resolveEntryRailAccountFooterState({
context: null,
loading: false,
failure: 'unavailable',
}, amrLoggedIn)).toBe('syncing');
},
);

it('still offers sign-in during an outage after an explicit local logout', () => {
expect(resolveEntryRailAccountFooterState({
context: null,
loading: false,
failure: 'unavailable',
}, false)).toBe('sign-in');
});

it('accepts the next successful null response as authoritative sign-out', () => {
expect(resolveEntryRailAccountFooterState({
context: null,
loading: false,
}, true)).toBe('sign-in');
});

it('preserves the legacy unsupported-daemon behavior', () => {
expect(resolveEntryRailAccountFooterState({
context: null,
loading: false,
failure: 'unsupported',
}, true)).toBe('sign-in');
});
});
Loading