Skip to content

Commit e6395ad

Browse files
committed
Again improved onboarding
1 parent 95a748a commit e6395ad

24 files changed

+990
-354
lines changed

browser/data-browser/src/Providers.tsx

Lines changed: 44 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { initBugsnag } from './helpers/loggingHandlers';
1717
import { ErrorBoundary } from './views/ErrorPage';
1818
import CrashPage from './views/CrashPage';
1919
import { AppSettingsContextProvider } from './helpers/AppSettings';
20+
import { RootWelcomeLayoutProvider } from './context/RootWelcomeLayoutContext';
2021
import { NavStateProvider } from './components/NavState';
2122
import { Toaster } from './components/Toaster';
2223
import { AISettingsContextProvider } from '@components/AI/AISettingsContext';
@@ -53,47 +54,49 @@ export const Providers: React.FC<React.PropsWithChildren> = ({ children }) => {
5354
<NavStateProvider>
5455
<LocaleProvider>
5556
<AppSettingsContextProvider>
56-
<AISettingsContextProvider>
57-
<LazyMCPProvider>
58-
<ControlLockProvider>
59-
<HotKeysWrapper>
60-
<StyleSheetManager shouldForwardProp={shouldForwardProp}>
61-
<ThemeWrapper>
62-
<GlobalStyle />
63-
<ErrBoundary FallbackComponent={CrashPage}>
64-
{/* Default form validation provider. Does not do anything on its own but will make sure useValidation works without context*/}
65-
<FormValidationContextProvider
66-
onValidationChange={() => undefined}
67-
>
68-
<Toaster />
69-
<CustomViewProvider>
70-
<MetaSetter />
71-
<DropdownContainer>
72-
<DialogGlobalContextProvider>
73-
<PopoverContainer>
74-
<DropdownContainer>
75-
<CustomContextItemsProvider>
76-
<NewResourceUIProvider>
77-
<SkipNav />
78-
<SearchOverlayContextProvider>
79-
<NavWrapper>{children}</NavWrapper>
80-
</SearchOverlayContextProvider>
81-
</NewResourceUIProvider>
82-
</CustomContextItemsProvider>
83-
</DropdownContainer>
84-
</PopoverContainer>
85-
<NetworkIndicator />
86-
</DialogGlobalContextProvider>
87-
</DropdownContainer>
88-
</CustomViewProvider>
89-
</FormValidationContextProvider>
90-
</ErrBoundary>
91-
</ThemeWrapper>
92-
</StyleSheetManager>
93-
</HotKeysWrapper>
94-
</ControlLockProvider>
95-
</LazyMCPProvider>
96-
</AISettingsContextProvider>
57+
<RootWelcomeLayoutProvider>
58+
<AISettingsContextProvider>
59+
<LazyMCPProvider>
60+
<ControlLockProvider>
61+
<HotKeysWrapper>
62+
<StyleSheetManager shouldForwardProp={shouldForwardProp}>
63+
<ThemeWrapper>
64+
<GlobalStyle />
65+
<ErrBoundary FallbackComponent={CrashPage}>
66+
{/* Default form validation provider. Does not do anything on its own but will make sure useValidation works without context*/}
67+
<FormValidationContextProvider
68+
onValidationChange={() => undefined}
69+
>
70+
<Toaster />
71+
<CustomViewProvider>
72+
<MetaSetter />
73+
<DropdownContainer>
74+
<DialogGlobalContextProvider>
75+
<PopoverContainer>
76+
<DropdownContainer>
77+
<CustomContextItemsProvider>
78+
<NewResourceUIProvider>
79+
<SkipNav />
80+
<SearchOverlayContextProvider>
81+
<NavWrapper>{children}</NavWrapper>
82+
</SearchOverlayContextProvider>
83+
</NewResourceUIProvider>
84+
</CustomContextItemsProvider>
85+
</DropdownContainer>
86+
</PopoverContainer>
87+
<NetworkIndicator />
88+
</DialogGlobalContextProvider>
89+
</DropdownContainer>
90+
</CustomViewProvider>
91+
</FormValidationContextProvider>
92+
</ErrBoundary>
93+
</ThemeWrapper>
94+
</StyleSheetManager>
95+
</HotKeysWrapper>
96+
</ControlLockProvider>
97+
</LazyMCPProvider>
98+
</AISettingsContextProvider>
99+
</RootWelcomeLayoutProvider>
97100
</AppSettingsContextProvider>
98101
</LocaleProvider>
99102
</NavStateProvider>
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import React, { FormEvent, useState } from 'react';
2+
import { Button } from './Button';
3+
import { Column, Row } from './Row';
4+
import Field from './forms/Field';
5+
import { InputStyled, InputWrapper } from './forms/InputStyles';
6+
import { FaKey } from 'react-icons/fa6';
7+
import { styled } from 'styled-components';
8+
9+
export type LoggedOutAgentPanelProps = {
10+
onCreateIdentityClick: () => void;
11+
/** Called when the user submits the sign-in form with a non-empty secret. */
12+
onSignInWithSecret: (secret: string) => void | Promise<void>;
13+
error?: Error | undefined;
14+
loading?: boolean;
15+
/** Defaults to `agent-secret` (matches User Settings). */
16+
fieldId?: string;
17+
};
18+
19+
/**
20+
* Shared “no agent yet” UI: create a new identity, or sign in with a secret.
21+
* Used on User Settings and the root welcome gate.
22+
*/
23+
export function LoggedOutAgentPanel({
24+
onCreateIdentityClick,
25+
onSignInWithSecret,
26+
error,
27+
loading = false,
28+
fieldId = 'agent-secret',
29+
}: LoggedOutAgentPanelProps) {
30+
const [secret, setSecret] = useState('');
31+
32+
async function handleSubmit(e: FormEvent) {
33+
e.preventDefault();
34+
const trimmed = secret.trim();
35+
if (!trimmed) {
36+
return;
37+
}
38+
39+
await onSignInWithSecret(trimmed);
40+
}
41+
42+
return (
43+
<Column gap='2rem'>
44+
<Column gap='1rem'>
45+
<h3>Create a new identity</h3>
46+
<p>
47+
Generate a new self-sovereign Agent and Drive on this server.
48+
</p>
49+
<Button type='button' onClick={onCreateIdentityClick}>
50+
Create new identity
51+
</Button>
52+
</Column>
53+
54+
<Divider />
55+
56+
<Column gap='1rem'>
57+
<h3>Sign in with existing secret</h3>
58+
<form onSubmit={handleSubmit}>
59+
<Column gap='1rem'>
60+
<Field
61+
label='Enter your Agent Secret'
62+
fieldId={fieldId}
63+
helper={
64+
"The Agent Secret is a long string of characters that encodes both the Subject and the Private Key. You can think of it as a combined username + password. Store it safely, and don't share it with others."
65+
}
66+
error={error}
67+
>
68+
<InputWrapper hasPrefix>
69+
<FaKey />
70+
<InputStyled
71+
id={fieldId}
72+
value={secret}
73+
onChange={e => setSecret(e.target.value)}
74+
type='password'
75+
name='secret'
76+
autoComplete='current-password'
77+
spellCheck={false}
78+
/>
79+
</InputWrapper>
80+
</Field>
81+
<Row gap='1rem'>
82+
<Button type='submit' disabled={loading || !secret.trim()}>
83+
{loading ? 'Signing in…' : 'Sign in'}
84+
</Button>
85+
</Row>
86+
</Column>
87+
</form>
88+
</Column>
89+
</Column>
90+
);
91+
}
92+
93+
const Divider = styled.hr`
94+
width: 100%;
95+
border: none;
96+
border-top: 1px solid ${p => p.theme.colors.bg2};
97+
margin: 0;
98+
`;

browser/data-browser/src/components/Navigation.tsx

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import { useResource, type Resource } from '@tomic/react';
1414
import NavBarContent from './NavBar';
1515
import { useLocation } from '@tanstack/react-router';
1616
import { useSettings } from '../helpers/AppSettings';
17+
import { paths } from '../routes/paths';
18+
import { useRootWelcomeLayout } from '../context/RootWelcomeLayoutContext';
1719

1820
interface NavWrapperProps {
1921
children: React.ReactNode;
@@ -24,8 +26,14 @@ const AISidebarMemo = React.memo(AISidebarContainer);
2426
/** Wraps the entire app and adds a navbar at the top or bottom */
2527
export function NavWrapper({ children }: NavWrapperProps): JSX.Element {
2628
const { navbarTop } = useSettings();
29+
const { rootWelcomeChromeHidden } = useRootWelcomeLayout();
2730
const [subject] = useCurrentSubject();
28-
const { searchStr } = useLocation();
31+
const { pathname, searchStr } = useLocation();
32+
33+
const onboardingOrChild =
34+
pathname === paths.onboarding ||
35+
pathname.startsWith(`${paths.onboarding}/`);
36+
const hideGlobalChrome = rootWelcomeChromeHidden || onboardingOrChild;
2937

3038
const search = useMemo(() => new URLSearchParams(searchStr), [searchStr]);
3139

@@ -43,15 +51,18 @@ export function NavWrapper({ children }: NavWrapperProps): JSX.Element {
4351

4452
return (
4553
<AISidebarContextProvider>
46-
<TopBar resource={resource} top={navbarTop} />
47-
<SideBarWrapper top={navbarTop}>
48-
<SideBar />
49-
<Content>
50-
{children}
51-
</Content>
52-
<HideInPrint>
53-
<AISidebarMemo />
54-
</HideInPrint>
54+
{!hideGlobalChrome && <TopBar resource={resource} top={navbarTop} />}
55+
<SideBarWrapper
56+
top={navbarTop}
57+
fullViewportContent={hideGlobalChrome}
58+
>
59+
{!hideGlobalChrome && <SideBar />}
60+
<Content>{children}</Content>
61+
{!hideGlobalChrome && (
62+
<HideInPrint>
63+
<AISidebarMemo />
64+
</HideInPrint>
65+
)}
5566
</SideBarWrapper>
5667
<OverlayContainer />
5768
</AISidebarContextProvider>
@@ -102,12 +113,23 @@ const NavBarStyled = styled.div<{ top: boolean }>`
102113
}
103114
`;
104115

105-
const SideBarWrapper = styled.div<{ top: boolean }>`
106-
${p => CalculatedPageHeight.define(`calc(100dvh - ${p.theme.heights.breadCrumbBar})`)}
116+
const SideBarWrapper = styled.div<{ top: boolean; fullViewportContent?: boolean }>`
117+
${p =>
118+
p.fullViewportContent
119+
? CalculatedPageHeight.define(`100dvh`)
120+
: CalculatedPageHeight.define(
121+
`calc(100dvh - ${p.theme.heights.breadCrumbBar})`,
122+
)}
107123
display: flex;
108124
height: ${CalculatedPageHeight.var()};
109125
position: fixed;
110-
${p => p.top ? `top: ${p.theme.heights.breadCrumbBar};` : 'top: 0;'}
126+
${p => {
127+
if (p.fullViewportContent) {
128+
return 'top: 0;';
129+
}
130+
131+
return p.top ? `top: ${p.theme.heights.breadCrumbBar};` : 'top: 0;';
132+
}}
111133
left: 0;
112134
right: 0;
113135

browser/data-browser/src/components/NewIdentitySection.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -478,9 +478,9 @@ function ProfileStep({
478478

479479
return (
480480
<Column gap='1rem'>
481-
<h3>You&apos;re signed in!</h3>
481+
<h3>Set your profile name!</h3>
482482
<p>
483-
Now, set your profile name. Note that this is only set for this specific
483+
Note that this is only set for this specific
484484
server, but you can use your secret also on other servers.
485485
</p>
486486
<form onSubmit={handleSave}>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import {
2+
createContext,
3+
useContext,
4+
useMemo,
5+
useState,
6+
type JSX,
7+
type ReactNode,
8+
} from 'react';
9+
10+
/**
11+
* Layout-only context: when the root URL shows the welcome gate, we hide global
12+
* chrome (sidebar, top bar, AI panel). Kept separate from AppSettings so
13+
* “user preferences” and “this screen’s shell” stay distinct.
14+
*/
15+
type Value = {
16+
rootWelcomeChromeHidden: boolean;
17+
setRootWelcomeChromeHidden: (hidden: boolean) => void;
18+
};
19+
20+
const RootWelcomeLayoutContext = createContext<Value | null>(null);
21+
22+
export function RootWelcomeLayoutProvider({
23+
children,
24+
}: {
25+
children: ReactNode;
26+
}): JSX.Element {
27+
const [rootWelcomeChromeHidden, setRootWelcomeChromeHidden] = useState(false);
28+
29+
const value = useMemo(
30+
() => ({ rootWelcomeChromeHidden, setRootWelcomeChromeHidden }),
31+
[rootWelcomeChromeHidden],
32+
);
33+
34+
return (
35+
<RootWelcomeLayoutContext.Provider value={value}>
36+
{children}
37+
</RootWelcomeLayoutContext.Provider>
38+
);
39+
}
40+
41+
export function useRootWelcomeLayout(): Value {
42+
const ctx = useContext(RootWelcomeLayoutContext);
43+
44+
if (!ctx) {
45+
throw new Error(
46+
'useRootWelcomeLayout must be used within RootWelcomeLayoutProvider',
47+
);
48+
}
49+
50+
return ctx;
51+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
/**
2+
* True when `subject` is the Atomic server's URL root (path `/`), for comparing
3+
* with {@link useSettings} `baseURL`. Used to show the self-host welcome gate.
4+
*/
5+
export function isAtomicServerHome(subject: string, baseURL: string): boolean {
6+
try {
7+
const sub = new URL(subject);
8+
const base = new URL(baseURL.endsWith('/') ? baseURL : `${baseURL}/`);
9+
if (sub.origin !== base.origin) {
10+
return false;
11+
}
12+
const path = sub.pathname.replace(/\/$/, '') || '/';
13+
14+
return path === '/';
15+
} catch {
16+
return false;
17+
}
18+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {
2+
type Agent,
3+
type Resource,
4+
isNotFound,
5+
isUnauthorized,
6+
} from '@tomic/react';
7+
import { isAtomicServerHome } from './isAtomicServerHome';
8+
9+
/**
10+
* True when loading the server home failed in a way that should show the
11+
* full-page welcome gate (fresh self-host, no agent, not found or need to sign in).
12+
*/
13+
export function isRootWelcomeResourceError(
14+
resource: Resource,
15+
agent: Agent | undefined,
16+
baseURL: string,
17+
): boolean {
18+
if (!resource.error) {
19+
return false;
20+
}
21+
22+
return (
23+
isAtomicServerHome(resource.subject, baseURL) &&
24+
!agent &&
25+
(isNotFound(resource.error) || isUnauthorized(resource.error))
26+
);
27+
}

0 commit comments

Comments
 (0)