History

Update dependencies and refactor routing in web application

- Added `react-router` dependency for improved routing capabilities. - Updated `@elysiajs/eden` to version 1.4.8 for enhanced functionality. - Implemented app shell rewrite exclusions in `next.config.ts` to streamline routing. - Replaced `AppShell` component with `StaticAppShell` in various pages for consistent layout handling. - Refactored navigation logic to utilize `react-router` hooks for better navigation management. - Removed unnecessary components and code from onboarding and organization pages to simplify structure.

Rhys Sullivan committed 9 months ago

Commit ad7cb931 parent b27ea0621 files · +400 −217Browse files

Unified · Split

21 files
  1. bun.lock +6 −1
  2. executor/apps/web/next.config.ts +21 −0
  3. executor/apps/web/package.json +1 −0
  4. executor/apps/web/src/app/approvals/page.tsx +2 −2
  5. executor/apps/web/src/app/layout.tsx +1 −2
  6. executor/apps/web/src/app/onboarding/page.tsx +2 −134
  7. executor/apps/web/src/app/organization/page.tsx +2 −7
  8. executor/apps/web/src/app/page.tsx +2 −2
  9. executor/apps/web/src/app/static-app-shell/page.tsx +5 −0
  10. executor/apps/web/src/app/tasks/page.tsx +2 −7
  11. executor/apps/web/src/app/tools/page.tsx +3 −14
  12. executor/apps/web/src/components/app-shell.tsx +86 −17
  13. executor/apps/web/src/components/approval-notifier.tsx +4 −4
  14. executor/apps/web/src/components/billing-view.tsx +2 −2
  15. executor/apps/web/src/components/dashboard-view.tsx +8 −8
  16. executor/apps/web/src/components/onboarding-view.tsx +136 −0
  17. executor/apps/web/src/components/organization-settings-view.tsx +6 −5
  18. executor/apps/web/src/components/static-app-shell.tsx +11 −0
  19. executor/apps/web/src/components/tasks-view.tsx +6 −6
  20. executor/apps/web/src/frontend/app.tsx +50 −0
  21. executor/apps/web/src/lib/session-context.tsx +44 −6
bun.lock+6−1
@@ -106,6 +106,7 @@ · 105 unmodified lines
106106 "radix-ui": "^1.4.3",
107107 "react": "19.2.3",
108108 "react-dom": "19.2.3",
109+ "react-router": "^7.9.5",
109110 "shiki": "^3.22.0",
110111 "sonner": "^2.0.7",
111112 "streamdown": "^2.1.0",
@@ -317,7 +318,7 @@ · 205 unmodified lines
317318
318319 "@effect/rpc": ["@effect/rpc@0.72.2", "", { "dependencies": { "msgpackr": "^1.11.4" }, "peerDependencies": { "@effect/platform": "^0.93.3", "effect": "^3.19.5" } }, "sha512-BmTXybXCOq96D2r9mvSW/YdiTQs5CStnd4II+lfVKrMr3pMNERKLZ2LG37Tfm4Sy3Q8ire6IVVKO/CN+VR0uQQ=="],
319320
320− "@elysiajs/eden": ["@elysiajs/eden@1.4.6", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-Tsa4NwXEWg/u73vWiYZQ3L5/ecgZSxqiEjYwpS+4qBKXeTZqZKl2hcgHJSVBL+InEDMi35Xugct7qyAXE5oM4Q=="],
321+ "@elysiajs/eden": ["@elysiajs/eden@1.4.8", "", { "peerDependencies": { "elysia": ">=1.4.19" } }, "sha512-a7oct2kFa49tH+GawZtSUCZR2rQgucNYgGLz8alXUqb4IrU3PASA0T4zXJw4MhdV1Xb6vyiR7p7kkqJcVjgbkA=="],
321322
322323 "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="],
323324
@@ -2189,6 +2190,8 @@ · 1865 unmodified lines
21892190
21902191 "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="],
21912192
2193+ "react-router": ["react-router@7.13.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw=="],
2194+
21922195 "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
21932196
21942197 "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
@@ -2269,6 +2272,8 @@ · 74 unmodified lines
22692272
22702273 "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
22712274
2275+ "set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="],
2276+
22722277 "set-function-length": ["set-function-length@1.2.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "function-bind": "^1.1.2", "get-intrinsic": "^1.2.4", "gopd": "^1.0.1", "has-property-descriptors": "^1.0.2" } }, "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg=="],
22732278
22742279 "set-function-name": ["set-function-name@2.0.2", "", { "dependencies": { "define-data-property": "^1.1.4", "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", "has-property-descriptors": "^1.0.2" } }, "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ=="],
executor/apps/web/next.config.ts+21−0
@@ -1,4 +1,17 @@
11 import type { NextConfig } from "next";
2+
3+const appShellRewriteExclusions = [
4+ "api(?:/|$)",
5+ "_next(?:/|$)",
6+ "favicon\\.ico$",
7+ "sign-in(?:/|$)",
8+ "sign-up(?:/|$)",
9+ "sign-out(?:/|$)",
10+ "callback(?:/|$)",
11+ "static-app-shell(?:/|$)",
12+ ".*\\..*",
13+].join("|");
14+
215 const nextConfig: NextConfig = {
316 transpilePackages: ["@executor/contracts"],
417 env: {
@@ -8,6 +21,14 @@ const nextConfig: NextConfig = { · 3 unmodified lines
821 NEXT_PUBLIC_WORKOS_CLIENT_ID: process.env.WORKOS_CLIENT_ID,
922 NEXT_PUBLIC_STRIPE_PRICE_ID: process.env.STRIPE_PRICE_ID,
1023 },
24+ async rewrites() {
25+ return [
26+ {
27+ source: `/((?!${appShellRewriteExclusions}).*)`,
28+ destination: "/static-app-shell",
29+ },
30+ ];
31+ },
1132 };
1233
1334 export default nextConfig;
executor/apps/web/package.json+1−0
@@ -28,6 +28,7 @@ · 27 unmodified lines
2828 "radix-ui": "^1.4.3",
2929 "react": "19.2.3",
3030 "react-dom": "19.2.3",
31+ "react-router": "^7.9.5",
3132 "shiki": "^3.22.0",
3233 "sonner": "^2.0.7",
3334 "streamdown": "^2.1.0",
executor/apps/web/src/app/approvals/page.tsx+2−2
@@ -1,5 +1,5 @@
1−import { ApprovalsView } from "@/components/approvals-view";
1+import { StaticAppShell } from "@/components/static-app-shell";
22
33 export default function ApprovalsPage() {
4− return <ApprovalsView />;
4+ return <StaticAppShell />;
55 }
executor/apps/web/src/app/layout.tsx+1−2
@@ -2,7 +2,6 @@ import type { Metadata } from "next"; · 1 unmodified line
22 import { JetBrains_Mono, Inter } from "next/font/google";
33 import { ThemeProvider } from "next-themes";
44 import { Toaster } from "@/components/ui/sonner";
5−import { AppShell } from "@/components/app-shell";
65 import { AppConvexProvider } from "@/lib/convex-provider";
76 import { QueryProvider } from "@/lib/query-provider";
87 import { SessionProvider } from "@/lib/session-context";
@@ -42,7 +41,7 @@ export default function RootLayout({ · 33 unmodified lines
4241 <AppConvexProvider>
4342 <QueryProvider>
4443 <SessionProvider>
45− <AppShell>{children}</AppShell>
44+ {children}
4645 </SessionProvider>
4746 </QueryProvider>
4847 </AppConvexProvider>
executor/apps/web/src/app/onboarding/page.tsx+2−134
@@ -1,137 +1,5 @@
1−"use client";
2−
3−import { useState, type FormEvent } from "react";
4−import Link from "next/link";
5−import { useRouter } from "next/navigation";
6−import { useMutation } from "convex/react";
7−import { Building2 } from "lucide-react";
8−import { Button } from "@/components/ui/button";
9−import {
10− Card,
11− CardContent,
12− CardDescription,
13− CardFooter,
14− CardHeader,
15− CardTitle,
16−} from "@/components/ui/card";
17−import { Input } from "@/components/ui/input";
18−import { convexApi } from "@/lib/convex-api";
19−import { useSession } from "@/lib/session-context";
1+import { StaticAppShell } from "@/components/static-app-shell";
202
213 export default function OnboardingPage() {
22− const router = useRouter();
23− const createOrganization = useMutation(convexApi.organizations.create);
24− const {
25− isSignedInToWorkos,
26− organizations,
27− organizationsLoading,
28− switchWorkspace,
29− } = useSession();
30−
31− const [name, setName] = useState("");
32− const [error, setError] = useState<string | null>(null);
33− const [creating, setCreating] = useState(false);
34−
35− const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
36− event.preventDefault();
37− const trimmed = name.trim();
38− if (trimmed.length < 2) {
39− setError("Organization name must be at least 2 characters.");
40− return;
41− }
42−
43− setCreating(true);
44− setError(null);
45− try {
46− const result = await createOrganization({ name: trimmed });
47− switchWorkspace(result.workspace.id);
48− router.replace("/");
49− } catch (cause) {
50− setError(cause instanceof Error ? cause.message : "Failed to create organization");
51− } finally {
52− setCreating(false);
53− }
54− };
55−
56− if (!isSignedInToWorkos) {
57− return (
58− <div className="min-h-[70vh] grid place-items-center">
59− <Card className="w-full max-w-xl border-border">
60− <CardHeader>
61− <CardTitle>Sign in required</CardTitle>
62− <CardDescription>
63− You need to sign in before setting up an organization.
64− </CardDescription>
65− </CardHeader>
66− <CardFooter className="justify-between gap-2">
67− <Button asChild variant="outline">
68− <Link href="/">Back</Link>
69− </Button>
70− <Button asChild>
71− <Link href="/sign-in">Sign in</Link>
72− </Button>
73− </CardFooter>
74− </Card>
75− </div>
76− );
77− }
78−
79− if (!organizationsLoading && organizations.length > 0) {
80− return (
81− <div className="min-h-[70vh] grid place-items-center">
82− <Card className="w-full max-w-xl border-border">
83− <CardHeader>
84− <CardTitle>Organization ready</CardTitle>
85− <CardDescription>
86− Your account is already linked to an organization.
87− </CardDescription>
88− </CardHeader>
89− <CardFooter>
90− <Button asChild className="w-full">
91− <Link href="/">Continue to dashboard</Link>
92− </Button>
93− </CardFooter>
94− </Card>
95− </div>
96− );
97− }
98−
99− return (
100− <div className="min-h-[80vh] grid place-items-center">
101− <Card className="w-full max-w-xl border-border">
102− <CardHeader>
103− <div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-md border border-border bg-muted">
104− <Building2 className="h-4 w-4 text-muted-foreground" />
105− </div>
106− <CardTitle>Name your organization</CardTitle>
107− <CardDescription>
108− This is your team space. You can invite members and create more workspaces later.
109− </CardDescription>
110− </CardHeader>
111− <form onSubmit={handleSubmit}>
112− <CardContent className="space-y-2">
113− <Input
114− value={name}
115− onChange={(event) => {
116− setError(null);
117− setName(event.target.value);
118− }}
119− placeholder="Acme"
120− maxLength={64}
121− autoFocus
122− />
123− {error ? <p className="text-xs text-destructive">{error}</p> : null}
124− </CardContent>
125− <CardFooter className="justify-end gap-2">
126− <Button asChild variant="outline" disabled={creating}>
127− <Link href="/sign-out">Sign out</Link>
128− </Button>
129− <Button type="submit" disabled={creating}>
130− {creating ? "Creating..." : "Continue"}
131− </Button>
132− </CardFooter>
133− </form>
134− </Card>
135− </div>
136− );
4+ return <StaticAppShell />;
1375 }
executor/apps/web/src/app/organization/page.tsx+2−7
@@ -1,10 +1,5 @@
1−import { Suspense } from "react";
2−import { OrganizationSettingsView } from "@/components/organization-settings-view";
1+import { StaticAppShell } from "@/components/static-app-shell";
32
43 export default function OrganizationPage() {
5− return (
6− <Suspense>
7− <OrganizationSettingsView />
8− </Suspense>
9− );
4+ return <StaticAppShell />;
105 }
executor/apps/web/src/app/page.tsx+2−2
@@ -1,5 +1,5 @@
1−import { DashboardView } from "@/components/dashboard-view";
1+import { StaticAppShell } from "@/components/static-app-shell";
22
33 export default function DashboardPage() {
4− return <DashboardView />;
4+ return <StaticAppShell />;
55 }
executor/apps/web/src/app/static-app-shell/page.tsx+5−0
@@ -0,0 +1,5 @@
1+import { StaticAppShell } from "@/components/static-app-shell";
2+
3+export default function StaticAppShellPage() {
4+ return <StaticAppShell />;
5+}
executor/apps/web/src/app/tasks/page.tsx+2−7
@@ -1,10 +1,5 @@
1−import { Suspense } from "react";
2−import { TasksView } from "@/components/tasks-view";
1+import { StaticAppShell } from "@/components/static-app-shell";
32
43 export default function TasksPage() {
5− return (
6− <Suspense>
7− <TasksView />
8− </Suspense>
9− );
4+ return <StaticAppShell />;
105 }
executor/apps/web/src/app/tools/page.tsx+3−14
@@ -1,16 +1,5 @@
1−import { ToolsView } from "@/components/tools-view";
1+import { StaticAppShell } from "@/components/static-app-shell";
22
3−export default async function ToolsPage({
4− searchParams,
5−}: {
6− searchParams: Promise<Record<string, string | string[] | undefined>>;
7−}) {
8− const params = await searchParams;
9− const source = typeof params.source === "string" ? params.source : null;
10−
11− return (
12− <div className="h-full min-h-0">
13− <ToolsView initialSource={source} />
14− </div>
15− );
3+export default function ToolsPage() {
4+ return <StaticAppShell />;
165 }
executor/apps/web/src/components/app-shell.tsx+86−17
@@ -1,8 +1,7 @@
11 "use client";
22
33 import { Suspense, useEffect, useMemo, useState, type FormEvent } from "react";
4−import Link from "next/link";
5−import { usePathname, useRouter, useSearchParams } from "next/navigation";
4+import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
65 import { useMutation } from "convex/react";
76 import {
87 LayoutDashboard,
@@ -38,7 +37,7 @@ import { · 29 unmodified lines
3837 import { Input } from "@/components/ui/input";
3938 import { useSession } from "@/lib/session-context";
4039 import { convexApi } from "@/lib/convex-api";
41−import { workosEnabled } from "@/lib/auth-capabilities";
40+import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities";
4241 import { ThemeSwitcher } from "@/components/theme-switcher";
4342 import { ApprovalNotifier } from "@/components/approval-notifier";
4443 import { cn } from "@/lib/utils";
@@ -51,7 +50,8 @@ const NAV_ITEMS = [ · 6 unmodified lines
5150 ];
5251
5352 function NavLinks({ onClick }: { onClick?: () => void }) {
54− const pathname = usePathname();
53+ const location = useLocation();
54+ const pathname = location.pathname;
5555
5656 return (
5757 <nav className="flex flex-col gap-1">
@@ -63,7 +63,7 @@ function NavLinks({ onClick }: { onClick?: () => void }) { · 5 unmodified lines
6363 return (
6464 <Link
6565 key={item.href}
66− href={item.href}
66+ to={item.href}
6767 onClick={onClick}
6868 className={cn(
6969 "flex items-center gap-3 px-3 py-2.5 rounded-md text-sm font-medium transition-colors",
@@ -82,12 +82,11 @@ function NavLinks({ onClick }: { onClick?: () => void }) { · 12 unmodified lines
8282 }
8383
8484 function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) {
85− const router = useRouter();
85+ const navigate = useNavigate();
8686 const createOrganizationMutation = useMutation(convexApi.organizations.create);
8787 const {
8888 context,
8989 mode,
90− clientConfig,
9190 workspaces,
9291 switchWorkspace,
9392 creatingWorkspace,
@@ -261,7 +260,7 @@ function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { · 167 unmodified lines
261260 className="inline-flex h-5 w-5 items-center justify-center rounded-sm border border-border text-muted-foreground hover:bg-accent hover:text-foreground"
262261 onClick={() => {
263262 switchWorkspace(settingsWorkspace.id);
264− router.push("/organization?tab=members");
263+ navigate("/organization?tab=members");
265264 }}
266265 aria-label={`Open ${group.organizationName} settings`}
267266 title="Organization settings"
@@ -402,14 +401,13 @@ function WorkspaceSelector({ inHeader = false }: { inHeader?: boolean }) { · 134 unmodified lines
402401 function SessionInfo() {
403402 const {
404403 loading,
405− clientConfig,
406404 isSignedInToWorkos,
407405 workosProfile,
408406 context,
409407 workspaces,
410408 resetWorkspace,
411409 } = useSession();
412− const searchParams = useSearchParams();
410+ const [searchParams] = useSearchParams();
413411 const deleteCurrentAccountMutation = useMutation(convexApi.accounts.deleteCurrentAccount);
414412 const [accountSettingsOpen, setAccountSettingsOpen] = useState(false);
415413 const [deleteConfirmText, setDeleteConfirmText] = useState("");
@@ -523,7 +521,7 @@ function SessionInfo() { · 107 unmodified lines
523521 Account Settings
524522 </DropdownMenuItem>
525523 <DropdownMenuItem asChild className="text-xs">
526− <Link href="/sign-out">Sign out</Link>
524+ <Link to="/sign-out" reloadDocument>Sign out</Link>
527525 </DropdownMenuItem>
528526 </DropdownMenuContent>
529527 </DropdownMenu>
@@ -587,7 +585,7 @@ function SessionInfo() { · 57 unmodified lines
587585 ) : (
588586 <div className="px-3 py-3 space-y-2">
589587 {workosEnabled ? (
590− <Link href={signInHref} className="block">
588+ <Link to={signInHref} reloadDocument className="block">
591589 <Button
592590 variant="outline"
593591 className="w-full h-9 justify-center gap-2 text-xs font-medium"
@@ -663,9 +661,79 @@ function MobileHeader() { · 69 unmodified lines
663661 );
664662 }
665663
664+function NoOrganizationModal({ enabled }: { enabled: boolean }) {
665+ const {
666+ loading,
667+ organizations,
668+ organizationsLoading,
669+ context,
670+ isSignedInToWorkos,
671+ createAnonymousWorkspace,
672+ creatingAnonymousWorkspace,
673+ } = useSession();
674+ const [error, setError] = useState<string | null>(null);
675+
676+ const shouldShow = enabled
677+ && !loading
678+ && !organizationsLoading
679+ && !context
680+ && !isSignedInToWorkos
681+ && organizations.length === 0;
682+
683+ const handleCreateAnonymousWorkspace = async () => {
684+ setError(null);
685+ try {
686+ await createAnonymousWorkspace();
687+ } catch (cause) {
688+ const message = cause instanceof Error ? cause.message : "Failed to create anonymous workspace";
689+ setError(message);
690+ }
691+ };
692+
693+ return (
694+ <Dialog open={shouldShow}>
695+ <DialogContent
696+ showCloseButton={false}
697+ onEscapeKeyDown={(event) => event.preventDefault()}
698+ onInteractOutside={(event) => event.preventDefault()}
699+ >
700+ <DialogHeader>
701+ <DialogTitle>Choose how to continue</DialogTitle>
702+ <DialogDescription>
703+ Sign in to access your organizations, or create an anonymous workspace to continue as a guest.
704+ </DialogDescription>
705+ </DialogHeader>
706+ <div className="space-y-2">
707+ {workosEnabled ? (
708+ <Button asChild className="w-full" disabled={creatingAnonymousWorkspace}>
709+ <Link to="/sign-in" reloadDocument className="gap-2">
710+ <LogIn className="h-4 w-4" />
711+ Sign in
712+ </Link>
713+ </Button>
714+ ) : null}
715+ <Button
716+ variant="outline"
717+ className="w-full"
718+ onClick={handleCreateAnonymousWorkspace}
719+ disabled={creatingAnonymousWorkspace || !anonymousDemoEnabled}
720+ >
721+ {creatingAnonymousWorkspace ? "Creating anonymous workspace..." : "Create anonymous workspace"}
722+ </Button>
723+ {!anonymousDemoEnabled ? (
724+ <p className="text-xs text-muted-foreground">Anonymous workspace creation is disabled.</p>
725+ ) : null}
726+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
727+ </div>
728+ </DialogContent>
729+ </Dialog>
730+ );
731+}
732+
666733 export function AppShell({ children }: { children: React.ReactNode }) {
667− const pathname = usePathname();
668− const router = useRouter();
734+ const location = useLocation();
735+ const pathname = location.pathname;
736+ const navigate = useNavigate();
669737 const { loading, organizations, organizationsLoading, isSignedInToWorkos } = useSession();
670738
671739 const onOnboardingRoute = pathname.startsWith("/onboarding");
@@ -677,12 +745,12 @@ export function AppShell({ children }: { children: React.ReactNode }) { · 5 unmodified lines
677745 }
678746
679747 if (needsOnboarding && !onOnboardingRoute) {
680− router.replace("/onboarding");
748+ navigate("/onboarding", { replace: true });
681749 return;
682750 }
683751
684752 if (!needsOnboarding && onOnboardingRoute && organizations.length > 0) {
685− router.replace("/");
753+ navigate("/", { replace: true });
686754 }
687755 }, [
688756 loading,
@@ -690,7 +758,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { · 1 unmodified line
690758 needsOnboarding,
691759 onOnboardingRoute,
692760 organizations.length,
693− router,
761+ navigate,
694762 ]);
695763
696764 if (isSignedInToWorkos && organizationsLoading && !onOnboardingRoute) {
@@ -712,6 +780,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { · 15 unmodified lines
712780 return (
713781 <div className="flex min-h-screen">
714782 <ApprovalNotifier />
783+ <NoOrganizationModal enabled />
715784 <Sidebar />
716785 <div className="flex-1 flex flex-col min-w-0">
717786 <MobileHeader />
executor/apps/web/src/components/approval-notifier.tsx+4−4
@@ -1,7 +1,7 @@
11 "use client";
22
33 import { useEffect, useRef } from "react";
4−import { useRouter } from "next/navigation";
4+import { useNavigate } from "react-router";
55 import { useQuery } from "convex/react";
66 import { toast } from "sonner";
77 import { useSession } from "@/lib/session-context";
@@ -9,7 +9,7 @@ import { convexApi } from "@/lib/convex-api"; · 1 unmodified line
99 import type { PendingApprovalRecord } from "@/lib/types";
1010
1111 export function ApprovalNotifier() {
12− const router = useRouter();
12+ const navigate = useNavigate();
1313 const { context } = useSession();
1414 const seenApprovalIdsRef = useRef<Set<string>>(new Set());
1515 const activeWorkspaceRef = useRef<string | null>(null);
@@ -58,12 +58,12 @@ export function ApprovalNotifier() { · 42 unmodified lines
5858 : "Open approvals to review pending tool calls.",
5959 action: {
6060 label: "Review",
61− onClick: () => router.push("/approvals"),
61+ onClick: () => navigate("/approvals"),
6262 },
6363 },
6464 );
6565
66− }, [approvals, context, router]);
66+ }, [approvals, context, navigate]);
6767
6868 return null;
6969 }
executor/apps/web/src/components/billing-view.tsx+2−2
@@ -1,7 +1,7 @@
11 "use client";
22
33 import { useMemo, useState } from "react";
4−import { useSearchParams } from "next/navigation";
4+import { useSearchParams } from "react-router";
55 import { useAction, useMutation, useQuery } from "convex/react";
66 import { AlertTriangle, BadgeCheck, CreditCard, RefreshCcw } from "lucide-react";
77 import { PageHeader } from "@/components/page-header";
@@ -50,7 +50,7 @@ function formatTimestamp(value: number | null | undefined): string { · 42 unmodified lines
5050
5151 export function BillingView({ showHeader = true }: BillingViewProps) {
5252 const { context, organizations, organizationsLoading, workspaces } = useSession();
53− const searchParams = useSearchParams();
53+ const [searchParams] = useSearchParams();
5454 const [actionState, setActionState] = useState<"idle" | "running" | "success" | "error">("idle");
5555 const [actionMessage, setActionMessage] = useState<string | null>(null);
5656 const [priceId, setPriceId] = useState(() => process.env.NEXT_PUBLIC_STRIPE_PRICE_ID ?? "");
executor/apps/web/src/components/dashboard-view.tsx+8−8
@@ -1,7 +1,7 @@
11 "use client";
22
33 import { useMemo } from "react";
4−import { useRouter } from "next/navigation";
4+import { useNavigate } from "react-router";
55 import {
66 Play,
77 ShieldCheck,
@@ -67,10 +67,10 @@ function StatCard({ · 59 unmodified lines
6767 }
6868
6969 function PendingApprovalRow({ approval }: { approval: PendingApprovalRecord }) {
70− const router = useRouter();
70+ const navigate = useNavigate();
7171 return (
7272 <button
73− onClick={() => router.push("/approvals")}
73+ onClick={() => navigate("/approvals")}
7474 className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group"
7575 >
7676 <div className="h-2 w-2 rounded-full bg-terminal-amber pulse-dot shrink-0" />
@@ -88,10 +88,10 @@ function PendingApprovalRow({ approval }: { approval: PendingApprovalRecord }) { · 11 unmodified lines
8888 }
8989
9090 function RecentTaskRow({ task }: { task: TaskRecord }) {
91− const router = useRouter();
91+ const navigate = useNavigate();
9292 return (
9393 <button
94− onClick={() => router.push(`/tasks?selected=${task.id}`)}
94+ onClick={() => navigate(`/tasks?selected=${task.id}`)}
9595 className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-accent/50 transition-colors text-left group"
9696 >
9797 <div className="flex-1 min-w-0">
@@ -122,7 +122,7 @@ function sourceType(source?: string): string { · 24 unmodified lines
122122 }
123123
124124 function ToolsSummaryCard({ tools }: { tools: ToolDescriptor[] }) {
125− const router = useRouter();
125+ const navigate = useNavigate();
126126
127127 const groups = useMemo(() => {
128128 const map = new Map<
@@ -180,7 +180,7 @@ function ToolsSummaryCard({ tools }: { tools: ToolDescriptor[] }) { · 51 unmodified lines
180180 variant="ghost"
181181 size="sm"
182182 className="text-xs h-7"
183− onClick={() => router.push("/tools")}
183+ onClick={() => navigate("/tools")}
184184 >
185185 Manage
186186 <ArrowRight className="h-3 w-3 ml-1" />
@@ -194,7 +194,7 @@ function ToolsSummaryCard({ tools }: { tools: ToolDescriptor[] }) { · 7 unmodified lines
194194 return (
195195 <button
196196 key={group.name}
197− onClick={() => router.push(`/tools?source=${encodeURIComponent(group.name)}`)}
197+ onClick={() => navigate(`/tools?source=${encodeURIComponent(group.name)}`)}
198198 className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-accent/40 transition-colors text-left group/row w-full"
199199 >
200200 <div className="h-7 w-7 rounded bg-muted flex items-center justify-center shrink-0">
executor/apps/web/src/components/onboarding-view.tsx+136−0
@@ -0,0 +1,136 @@
1+"use client";
2+
3+import { useState, type FormEvent } from "react";
4+import { Link, useNavigate } from "react-router";
5+import { useMutation } from "convex/react";
6+import { Building2 } from "lucide-react";
7+import { Button } from "@/components/ui/button";
8+import {
9+ Card,
10+ CardContent,
11+ CardDescription,
12+ CardFooter,
13+ CardHeader,
14+ CardTitle,
15+} from "@/components/ui/card";
16+import { Input } from "@/components/ui/input";
17+import { convexApi } from "@/lib/convex-api";
18+import { useSession } from "@/lib/session-context";
19+
20+export function OnboardingView() {
21+ const navigate = useNavigate();
22+ const createOrganization = useMutation(convexApi.organizations.create);
23+ const {
24+ isSignedInToWorkos,
25+ organizations,
26+ organizationsLoading,
27+ switchWorkspace,
28+ } = useSession();
29+
30+ const [name, setName] = useState("");
31+ const [error, setError] = useState<string | null>(null);
32+ const [creating, setCreating] = useState(false);
33+
34+ const handleSubmit = async (event: FormEvent<HTMLFormElement>) => {
35+ event.preventDefault();
36+ const trimmed = name.trim();
37+ if (trimmed.length < 2) {
38+ setError("Organization name must be at least 2 characters.");
39+ return;
40+ }
41+
42+ setCreating(true);
43+ setError(null);
44+ try {
45+ const result = await createOrganization({ name: trimmed });
46+ switchWorkspace(result.workspace.id);
47+ navigate("/", { replace: true });
48+ } catch (cause) {
49+ setError(cause instanceof Error ? cause.message : "Failed to create organization");
50+ } finally {
51+ setCreating(false);
52+ }
53+ };
54+
55+ if (!isSignedInToWorkos) {
56+ return (
57+ <div className="min-h-[70vh] grid place-items-center">
58+ <Card className="w-full max-w-xl border-border">
59+ <CardHeader>
60+ <CardTitle>Sign in required</CardTitle>
61+ <CardDescription>
62+ You need to sign in before setting up an organization.
63+ </CardDescription>
64+ </CardHeader>
65+ <CardFooter className="justify-between gap-2">
66+ <Button asChild variant="outline">
67+ <Link to="/">Back</Link>
68+ </Button>
69+ <Button asChild>
70+ <Link to="/sign-in" reloadDocument>Sign in</Link>
71+ </Button>
72+ </CardFooter>
73+ </Card>
74+ </div>
75+ );
76+ }
77+
78+ if (!organizationsLoading && organizations.length > 0) {
79+ return (
80+ <div className="min-h-[70vh] grid place-items-center">
81+ <Card className="w-full max-w-xl border-border">
82+ <CardHeader>
83+ <CardTitle>Organization ready</CardTitle>
84+ <CardDescription>
85+ Your account is already linked to an organization.
86+ </CardDescription>
87+ </CardHeader>
88+ <CardFooter>
89+ <Button asChild className="w-full">
90+ <Link to="/">Continue to dashboard</Link>
91+ </Button>
92+ </CardFooter>
93+ </Card>
94+ </div>
95+ );
96+ }
97+
98+ return (
99+ <div className="min-h-[80vh] grid place-items-center">
100+ <Card className="w-full max-w-xl border-border">
101+ <CardHeader>
102+ <div className="mb-2 inline-flex h-9 w-9 items-center justify-center rounded-md border border-border bg-muted">
103+ <Building2 className="h-4 w-4 text-muted-foreground" />
104+ </div>
105+ <CardTitle>Name your organization</CardTitle>
106+ <CardDescription>
107+ This is your team space. You can invite members and create more workspaces later.
108+ </CardDescription>
109+ </CardHeader>
110+ <form onSubmit={handleSubmit}>
111+ <CardContent className="space-y-2">
112+ <Input
113+ value={name}
114+ onChange={(event) => {
115+ setError(null);
116+ setName(event.target.value);
117+ }}
118+ placeholder="Acme"
119+ maxLength={64}
120+ autoFocus
121+ />
122+ {error ? <p className="text-xs text-destructive">{error}</p> : null}
123+ </CardContent>
124+ <CardFooter className="justify-end gap-2">
125+ <Button asChild variant="outline" disabled={creating}>
126+ <Link to="/sign-out" reloadDocument>Sign out</Link>
127+ </Button>
128+ <Button type="submit" disabled={creating}>
129+ {creating ? "Creating..." : "Continue"}
130+ </Button>
131+ </CardFooter>
132+ </form>
133+ </Card>
134+ </div>
135+ );
136+}
executor/apps/web/src/components/organization-settings-view.tsx+6−5
@@ -1,7 +1,7 @@
11 "use client";
22
33 import { useMemo } from "react";
4−import { usePathname, useRouter, useSearchParams } from "next/navigation";
4+import { useLocation, useNavigate, useSearchParams } from "react-router";
55 import { CreditCard, Users } from "lucide-react";
66 import { BillingView } from "@/components/billing-view";
77 import { MembersView } from "@/components/members-view";
@@ -15,9 +15,10 @@ function normalizeTab(value: string | null): OrganizationTab { · 7 unmodified lines
1515 }
1616
1717 export function OrganizationSettingsView() {
18− const router = useRouter();
19− const pathname = usePathname();
20− const searchParams = useSearchParams();
18+ const navigate = useNavigate();
19+ const location = useLocation();
20+ const pathname = location.pathname;
21+ const [searchParams] = useSearchParams();
2122
2223 const tab = useMemo(() => normalizeTab(searchParams.get("tab")), [searchParams]);
2324
@@ -25,7 +26,7 @@ export function OrganizationSettingsView() { · 1 unmodified line
2526 const normalizedTab = normalizeTab(nextTab);
2627 const nextSearch = new URLSearchParams(searchParams.toString());
2728 nextSearch.set("tab", normalizedTab);
28− router.replace(`${pathname}?${nextSearch.toString()}`);
29+ navigate(`${pathname}?${nextSearch.toString()}`, { replace: true });
2930 };
3031
3132 return (
executor/apps/web/src/components/static-app-shell.tsx+11−0
@@ -0,0 +1,11 @@
1+"use client";
2+
3+import dynamic from "next/dynamic";
4+
5+const FrontendApp = dynamic(() => import("@/frontend/app"), {
6+ ssr: false,
7+});
8+
9+export function StaticAppShell() {
10+ return <FrontendApp />;
11+}
executor/apps/web/src/components/tasks-view.tsx+6−6
@@ -1,7 +1,7 @@
11 "use client";
22
33 import { useState, useCallback, useMemo } from "react";
4−import { useSearchParams, useRouter } from "next/navigation";
4+import { useNavigate, useSearchParams } from "react-router";
55 import {
66 Play,
77 ChevronRight,
@@ -361,8 +361,8 @@ function TaskDetail({ · 353 unmodified lines
361361
362362 export function TasksView() {
363363 const { context, loading: sessionLoading } = useSession();
364− const router = useRouter();
365− const searchParams = useSearchParams();
364+ const navigate = useNavigate();
365+ const [searchParams] = useSearchParams();
366366 const selectedId = searchParams.get("selected");
367367
368368 const tasks = useQuery(
@@ -377,12 +377,12 @@ export function TasksView() { · 8 unmodified lines
377377 const selectTask = useCallback(
378378 (taskId: string | null) => {
379379 if (taskId) {
380− router.push(`/tasks?selected=${taskId}`, { scroll: false });
380+ navigate(`/tasks?selected=${taskId}`);
381381 } else {
382− router.push("/tasks", { scroll: false });
382+ navigate("/tasks");
383383 }
384384 },
385− [router],
385+ [navigate],
386386 );
387387
388388 if (sessionLoading) {
executor/apps/web/src/frontend/app.tsx+50−0
@@ -0,0 +1,50 @@
1+"use client";
2+
3+import { Navigate, Outlet, Route, Routes, BrowserRouter, useSearchParams } from "react-router";
4+import { AppShell } from "@/components/app-shell";
5+import { ApprovalsView } from "@/components/approvals-view";
6+import { DashboardView } from "@/components/dashboard-view";
7+import { OnboardingView } from "@/components/onboarding-view";
8+import { OrganizationSettingsView } from "@/components/organization-settings-view";
9+import { TasksView } from "@/components/tasks-view";
10+import { ToolsView } from "@/components/tools-view";
11+
12+function ToolsRoute() {
13+ const [searchParams] = useSearchParams();
14+ const source = searchParams.get("source");
15+
16+ return (
17+ <div className="h-full min-h-0">
18+ <ToolsView initialSource={source} />
19+ </div>
20+ );
21+}
22+
23+function ShellLayout() {
24+ return (
25+ <AppShell>
26+ <Outlet />
27+ </AppShell>
28+ );
29+}
30+
31+export default function FrontendApp() {
32+ return (
33+ <BrowserRouter>
34+ <Routes>
35+ <Route element={<ShellLayout />}>
36+ <Route path="/" element={<DashboardView />} />
37+ <Route path="/static-app-shell" element={<DashboardView />} />
38+ <Route path="/tasks" element={<TasksView />} />
39+ <Route path="/approvals" element={<ApprovalsView />} />
40+ <Route path="/tools" element={<ToolsRoute />} />
41+ <Route path="/organization" element={<OrganizationSettingsView />} />
42+ <Route path="/onboarding" element={<OnboardingView />} />
43+ <Route path="/members" element={<Navigate to="/organization?tab=members" replace />} />
44+ <Route path="/billing" element={<Navigate to="/organization?tab=billing" replace />} />
45+ <Route path="*" element={<Navigate to="/" replace />} />
46+ </Route>
47+ </Routes>
48+ </BrowserRouter>
49+ );
50+}
executor/apps/web/src/lib/session-context.tsx+44−6
@@ -10,7 +10,7 @@ import { · 9 unmodified lines
1010 } from "react";
1111 import { useMutation, useQuery as useConvexQuery } from "convex/react";
1212 import { useQuery as useTanstackQuery } from "@tanstack/react-query";
13−import { workosEnabled } from "@/lib/auth-capabilities";
13+import { anonymousDemoEnabled, workosEnabled } from "@/lib/auth-capabilities";
1414 import { useWorkosAuthLoading } from "@/lib/convex-provider";
1515 import { convexApi } from "@/lib/convex-api";
1616 import type { AnonymousContext } from "./types";
@@ -54,6 +54,8 @@ interface SessionState { · 37 unmodified lines
5454 iconFile?: File | null,
5555 organizationId?: Id<"organizations">,
5656 ) => Promise<void>;
57+ creatingAnonymousWorkspace: boolean;
58+ createAnonymousWorkspace: () => Promise<void>;
5759 isSignedInToWorkos: boolean;
5860 workosProfile: {
5961 name: string;
@@ -95,6 +97,8 @@ const SessionContext = createContext<SessionState>({ · 35 unmodified lines
9597 switchWorkspace: () => {},
9698 creatingWorkspace: false,
9799 createWorkspace: async () => {},
100+ creatingAnonymousWorkspace: false,
101+ createAnonymousWorkspace: async () => {},
98102 isSignedInToWorkos: false,
99103 workosProfile: null,
100104 resetWorkspace: async () => {},
@@ -143,13 +147,20 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 42 unmodified lines
143147 const createWorkspaceMutation = useMutation(convexApi.workspaces.create);
144148 const generateWorkspaceIconUploadUrl = useMutation(convexApi.workspaces.generateWorkspaceIconUploadUrl);
145149 const [creatingWorkspace, setCreatingWorkspace] = useState(false);
150+ const [creatingAnonymousWorkspace, setCreatingAnonymousWorkspace] = useState(false);
151+ const [manualGuestContext, setManualGuestContext] = useState<AnonymousContext | null>(null);
146152
147153 const bootstrapSessionQuery = useTanstackQuery({
148154 queryKey: ["session-bootstrap", storedSessionId ?? "new"],
155+ enabled: storedSessionId !== null,
149156 retry: false,
150157 staleTime: Number.POSITIVE_INFINITY,
151158 queryFn: async () => {
152− const context = await bootstrapAnonymousSession({ sessionId: storedSessionId ?? undefined });
159+ if (!storedSessionId) {
160+ throw new Error("No guest session id available");
161+ }
162+
163+ const context = await bootstrapAnonymousSession({ sessionId: storedSessionId });
153164 localStorage.setItem(SESSION_KEY, context.sessionId);
154165 if (context.sessionId !== storedSessionId) {
155166 setStoredSessionId(context.sessionId);
@@ -158,7 +169,7 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 2 unmodified lines
158169 },
159170 });
160171
161− const guestContext: AnonymousContext | null = bootstrapSessionQuery.data ?? null;
172+ const guestContext: AnonymousContext | null = manualGuestContext ?? bootstrapSessionQuery.data ?? null;
162173
163174 const account = useConvexQuery(
164175 convexApi.app.getCurrentAccount,
@@ -204,9 +215,33 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 39 unmodified lines
204215 localStorage.removeItem(ACTIVE_WORKSPACE_KEY);
205216 setStoredSessionId(null);
206217 setActiveWorkspaceId(null);
218+ setManualGuestContext(null);
207219 setRuntimeError(null);
208220 }, []);
209221
222+ const createAnonymousWorkspace = useCallback(async () => {
223+ if (!anonymousDemoEnabled) {
224+ throw new Error("Anonymous workspace creation is disabled");
225+ }
226+
227+ setRuntimeError(null);
228+ setCreatingAnonymousWorkspace(true);
229+ try {
230+ const context = await bootstrapAnonymousSession({});
231+ localStorage.setItem(SESSION_KEY, context.sessionId);
232+ localStorage.setItem(ACTIVE_WORKSPACE_KEY, context.workspaceId);
233+ setStoredSessionId(context.sessionId);
234+ setActiveWorkspaceId(context.workspaceId as Id<"workspaces">);
235+ setManualGuestContext(context);
236+ } catch (cause) {
237+ const message = cause instanceof Error ? cause.message : "Failed to create anonymous workspace";
238+ setRuntimeError(message);
239+ throw cause;
240+ } finally {
241+ setCreatingAnonymousWorkspace(false);
242+ }
243+ }, [bootstrapAnonymousSession]);
244+
210245 const switchWorkspace = useCallback((workspaceId: Id<"workspaces">) => {
211246 setActiveWorkspaceId(workspaceId);
212247 localStorage.setItem(ACTIVE_WORKSPACE_KEY, workspaceId);
@@ -318,9 +353,9 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 105 unmodified lines
318353 const context = workosContext ?? ((workosStillLoading || shouldBlockGuestFallback) ? null : guestContext);
319354
320355 const bootstrapSessionError =
321− bootstrapSessionQuery.error instanceof Error
356+ storedSessionId && bootstrapSessionQuery.error instanceof Error
322357 ? bootstrapSessionQuery.error.message
323− : bootstrapSessionQuery.error
358+ : storedSessionId && bootstrapSessionQuery.error
324359 ? "Failed to bootstrap session"
325360 : null;
326361 const bootstrapWorkosError =
@@ -332,7 +367,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 5 unmodified lines
332367 const error = runtimeError ?? bootstrapSessionError ?? bootstrapWorkosError;
333368
334369 const effectiveLoading = !context && !error && (
335− bootstrapSessionQuery.isLoading
370+ creatingAnonymousWorkspace
371+ || (storedSessionId !== null && bootstrapSessionQuery.isLoading)
336372 || workosStillLoading
337373 || bootstrapWorkosAccountQuery.isFetching
338374 );
@@ -379,6 +415,8 @@ export function SessionProvider({ children }: { children: ReactNode }) { · 40 unmodified lines
379415 switchWorkspace,
380416 creatingWorkspace,
381417 createWorkspace,
418+ creatingAnonymousWorkspace,
419+ createAnonymousWorkspace,
382420 isSignedInToWorkos: Boolean(account && account.provider === "workos"),
383421 workosProfile:
384422 account && account.provider === "workos"