diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts
index 1b1c07b31c9..232551349db 100644
--- a/apps/web/src/hooks/useHandleNewThread.ts
+++ b/apps/web/src/hooks/useHandleNewThread.ts
@@ -47,6 +47,7 @@ export function useNewThreadHandler() {
worktreePath?: string | null;
envMode?: DraftThreadEnvMode;
startFromOrigin?: boolean;
+ replace?: boolean;
},
): Promise => {
const {
@@ -120,6 +121,7 @@ export function useNewThreadHandler() {
await router.navigate({
to: "/draft/$draftId",
params: { draftId: reusableStoredDraftThread.draftId },
+ replace: options?.replace ?? false,
});
})();
}
@@ -180,6 +182,7 @@ export function useNewThreadHandler() {
await router.navigate({
to: "/draft/$draftId",
params: { draftId },
+ replace: options?.replace ?? false,
});
})();
},
diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx
index 94d49d00afe..48462b937a7 100644
--- a/apps/web/src/routes/_chat.index.tsx
+++ b/apps/web/src/routes/_chat.index.tsx
@@ -1,10 +1,19 @@
+import { scopeProjectRef } from "@t3tools/client-runtime/environment";
import { createFileRoute, Link } from "@tanstack/react-router";
import { LinkIcon, PlusIcon } from "lucide-react";
+import { useEffect, useMemo, useRef } from "react";
-import { NoActiveThreadState } from "../components/NoActiveThreadState";
+import { useOpenAddProjectCommandPalette } from "../commandPaletteContext";
+import { sortProjectsForSidebar } from "../components/Sidebar.logic";
import { Button } from "../components/ui/button";
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "../components/ui/empty";
import { SidebarInset } from "../components/ui/sidebar";
+import { useNewThreadHandler } from "../hooks/useHandleNewThread";
+import {
+ useAllEnvironmentShellsBootstrapped,
+ useProjects,
+ useThreadShells,
+} from "../state/entities";
import { useEnvironments } from "../state/environments";
import { APP_DISPLAY_NAME } from "~/branding";
import { hasCloudPublicConfig } from "~/cloud/publicConfig";
@@ -19,7 +28,72 @@ function ChatIndexRouteView() {
return ;
}
- return ;
+ return ;
+}
+
+/**
+ * Landing on the index route drops straight into a draft thread for the most
+ * recently active project, so the first screen is a prompt instead of a dead
+ * end. Falls back to an add-project hero when no project exists yet.
+ */
+function IndexDraftLanding() {
+ const projects = useProjects();
+ const threads = useThreadShells();
+ const bootstrapped = useAllEnvironmentShellsBootstrapped();
+ const handleNewThread = useNewThreadHandler();
+ const startingRef = useRef(false);
+
+ const mostRecentProject = useMemo(
+ () =>
+ bootstrapped ? (sortProjectsForSidebar(projects, threads, "updated_at")[0] ?? null) : null,
+ [bootstrapped, projects, threads],
+ );
+
+ useEffect(() => {
+ if (mostRecentProject === null || startingRef.current) {
+ return;
+ }
+ startingRef.current = true;
+ void handleNewThread(scopeProjectRef(mostRecentProject.environmentId, mostRecentProject.id), {
+ replace: true,
+ }).catch(() => {
+ startingRef.current = false;
+ });
+ }, [handleNewThread, mostRecentProject]);
+
+ if (!bootstrapped || mostRecentProject !== null) {
+ return null;
+ }
+ return ;
+}
+
+function NoProjectsHero() {
+ const openAddProject = useOpenAddProjectCommandPalette();
+
+ return (
+
+
+
+
+
+
+ What should we work on?
+
+
+ Add a project to start your first thread.
+
+
+
+
+
+
+
+ );
}
export const Route = createFileRoute("/_chat/")({
diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts
index b4fc8cc5e80..db895e37c4a 100644
--- a/apps/web/src/state/entities.ts
+++ b/apps/web/src/state/entities.ts
@@ -20,6 +20,7 @@ import { useMemo } from "react";
import { appAtomRegistry } from "../rpc/atomRegistry";
import { environmentProjects } from "./projects";
import { environmentServerConfigsAtom } from "./server";
+import { allEnvironmentShellsBootstrappedAtom } from "./shell";
import { environmentThreadDetails, environmentThreadShells } from "./threads";
const EMPTY_PROJECT_REFS: ReadonlyArray = Object.freeze([]);
@@ -113,6 +114,10 @@ export function useThreadShells(): ReadonlyArray {
return useAtomValue(environmentThreadShells.threadShellsAtom);
}
+export function useAllEnvironmentShellsBootstrapped(): boolean {
+ return useAtomValue(allEnvironmentShellsBootstrappedAtom);
+}
+
export function useThreadShellsForProjectRefs(
refs: ReadonlyArray,
): ReadonlyArray {
diff --git a/apps/web/src/state/shell.ts b/apps/web/src/state/shell.ts
index e879dd25e29..dfb104e5c99 100644
--- a/apps/web/src/state/shell.ts
+++ b/apps/web/src/state/shell.ts
@@ -1,9 +1,15 @@
+import {
+ AVAILABLE_CONNECTION_STATE,
+ connectionProjectionPhase,
+} from "@t3tools/client-runtime/connection";
import {
createEnvironmentShellAtoms,
createEnvironmentShellSummaryAtom,
createEnvironmentSnapshotAtom,
createShellEnvironmentAtoms,
} from "@t3tools/client-runtime/state/shell";
+import * as Option from "effect/Option";
+import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { environmentCatalog } from "../connection/catalog";
import { connectionAtomRuntime } from "../connection/runtime";
@@ -15,3 +21,28 @@ export const environmentShellSummaryAtom = createEnvironmentShellSummaryAtom({
catalogValueAtom: environmentCatalog.catalogValueAtom,
shellStateValueAtom: environmentShell.stateValueAtom,
});
+
+export const allEnvironmentShellsBootstrappedAtom = Atom.make((get) => {
+ const catalog = AsyncResult.value(get(environmentCatalog.catalogAtom));
+ if (Option.isNone(catalog)) {
+ return false;
+ }
+ for (const environmentId of catalog.value.entries.keys()) {
+ if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) {
+ continue;
+ }
+ const connection = Option.getOrElse(
+ AsyncResult.value(get(environmentCatalog.stateAtom(environmentId))),
+ () => AVAILABLE_CONNECTION_STATE,
+ );
+ if (connectionProjectionPhase(connection) !== "disconnected") {
+ return false;
+ }
+ // A retrying environment is only transiently disconnected; give it its
+ // first retries before letting the landing settle without its snapshot.
+ if (connection.phase === "backoff" && connection.desired && connection.attempt <= 2) {
+ return false;
+ }
+ }
+ return true;
+}).pipe(Atom.withLabel("web-all-environment-shells-bootstrapped"));