diff --git a/apps/web/src/launchVisit.ts b/apps/web/src/launchVisit.ts
new file mode 100644
index 000000000..16625fb2b
--- /dev/null
+++ b/apps/web/src/launchVisit.ts
@@ -0,0 +1,24 @@
+/**
+ * Whether this app load has already had its launch moment: the one navigation
+ * that lands the user in their work (route restore, the default-project draft,
+ * or the server's bootstrap welcome payload).
+ *
+ * The index route consults this to tell a launch apart from a deliberate trip
+ * home ("Go to Home", the settings back button): the launch visit redirects,
+ * everything after renders the home surface. It lives outside the index route
+ * because the launch does not necessarily pass through `/` at all -- the
+ * bootstrap welcome payload and a desktop deep link both land directly on a
+ * thread route -- and being on any thread means the launch already happened.
+ *
+ * @module launchVisit
+ */
+
+let consumed = false;
+
+export function isLaunchVisitConsumed(): boolean {
+ return consumed;
+}
+
+export function markLaunchVisitConsumed(): void {
+ consumed = true;
+}
diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx
index db3209be6..8cb596751 100644
--- a/apps/web/src/routes/_chat.index.tsx
+++ b/apps/web/src/routes/_chat.index.tsx
@@ -1,7 +1,7 @@
import { scopedProjectKey, scopeThreadRef } from "@threadlines/client-runtime";
import type { ThreadId } from "@threadlines/contracts";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
-import { useEffect, useEffectEvent } from "react";
+import { useEffect, useEffectEvent, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import {
@@ -20,6 +20,7 @@ import {
readLastVisitedThreadRoute,
resolveRestorableThreadRoute,
} from "../lastVisitedThreadRoute";
+import { isLaunchVisitConsumed, markLaunchVisitConsumed } from "../launchVisit";
import {
selectBootstrapCompleteForActiveEnvironment,
selectProjectsAcrossEnvironments,
@@ -65,36 +66,38 @@ function ChatIndexRouteView() {
}
}
-/**
- * Restoring is something a launch does, not a rule about `/`. Later trips home
- * are deliberate ("Go to Home", the settings back button with no history left)
- * and must land on the home surface instead of being bounced straight back, so
- * the record is consulted once per app load.
- */
-let restoreAttempted = false;
-
/**
* What `/` resolves to once the workspace has loaded.
*
- * A relaunch lands here with no route to speak of, so the last thread this
- * environment had open wins when it still exists -- otherwise this falls back
- * to the long-standing behaviour of opening a draft in the default project.
- * The record is checked against live state rather than trusted, so a thread
- * deleted elsewhere never strands the shell on a dead route.
+ * The launch visit redirects into work: the last thread or draft this
+ * environment had open when it still exists, else a fresh draft in the default
+ * project (the long-standing cold-start behaviour). Every visit after that is
+ * a navigation the user chose, and renders the home surface instead. The
+ * restore record is checked against live state rather than trusted, so a
+ * thread deleted elsewhere never strands the shell on a dead route.
*/
function DefaultProjectDraftRedirect() {
const navigate = useNavigate();
const { defaultProjectRef, handleNewThread } = useHandleNewThread();
const activeEnvironmentId = useStore((state) => state.activeEnvironmentId);
const defaultProjectKey = defaultProjectRef ? scopedProjectKey(defaultProjectRef) : null;
+ // Captured at mount: a mount that begins after the launch moment passed --
+ // it is marked the instant any thread or draft route renders, including via
+ // the bootstrap welcome payload, which never touches this route -- is a
+ // deliberate trip home and must never redirect.
+ const [isLaunchVisit] = useState(() => !isLaunchVisitConsumed());
const openLastVisitedOrDefaultDraft = useEffectEvent(() => {
- const entry = restoreAttempted ? null : readLastVisitedThreadRoute(activeEnvironmentId);
- if (activeEnvironmentId) {
- // Only counts as the load-time attempt once there is an environment to
- // read a record from; before that there was nothing to restore.
- restoreAttempted = true;
+ if (!isLaunchVisit || isLaunchVisitConsumed()) {
+ return;
+ }
+ if (!activeEnvironmentId) {
+ // Nothing to restore or redirect into yet; the effect re-runs once the
+ // environment arrives, still counting as the launch visit.
+ return;
}
+ markLaunchVisitConsumed();
+ const entry = readLastVisitedThreadRoute(activeEnvironmentId);
const restored = resolveRestorableThreadRoute({
entry,
environmentId: activeEnvironmentId,
@@ -136,7 +139,9 @@ function DefaultProjectDraftRedirect() {
openLastVisitedOrDefaultDraft();
}, [activeEnvironmentId, defaultProjectKey]);
- return defaultProjectRef === null ? : null;
+ // The launch visit renders nothing while its redirect resolves (unless there
+ // is no project to redirect into); a deliberate visit IS the home surface.
+ return isLaunchVisit && defaultProjectRef !== null ? null : ;
}
export const Route = createFileRoute("/_chat/")({
diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx
index af52defb4..166efc4a5 100644
--- a/apps/web/src/routes/_chat.tsx
+++ b/apps/web/src/routes/_chat.tsx
@@ -6,6 +6,7 @@ import { useCommandPaletteStore } from "../commandPaletteStore";
import { useComposerDraftStore } from "../composerDraftStore";
import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { recordLastVisitedThreadRoute } from "../lastVisitedThreadRoute";
+import { markLaunchVisitConsumed } from "../launchVisit";
import { resolveThreadRouteTarget } from "../threadRoutes";
import { useStore } from "../store";
import {
@@ -127,6 +128,9 @@ function LastVisitedThreadRouteRecorder() {
useEffect(() => {
if (routeThreadEnvironmentId && routeThreadId) {
+ // Being on any thread means the launch already landed somewhere; a later
+ // trip to `/` is deliberate and must render the home surface.
+ markLaunchVisitConsumed();
recordLastVisitedThreadRoute(routeThreadEnvironmentId, {
kind: "server",
threadRef: scopeThreadRef(routeThreadEnvironmentId, routeThreadId),
@@ -134,6 +138,7 @@ function LastVisitedThreadRouteRecorder() {
return;
}
if (routeDraftId) {
+ markLaunchVisitConsumed();
recordLastVisitedThreadRoute(draftEnvironmentId ?? activeEnvironmentId, {
kind: "draft",
draftId: routeDraftId,