From 246b346caa7ca8c31cdb256f5badf40da0b238d6 Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Sat, 8 Aug 2026 14:02:05 -0400
Subject: [PATCH 1/2] Make deliberate trips to Home land on the home surface
The index route redirected into a default-project draft on every visit,
which made "Go to Home" and the settings back button teleport the user
into a fresh composer they didn't ask for (and quietly mint drafts as a
side effect). The redirect is launch behaviour, not a rule about the
route: the launch visit still restores the last thread or opens the
default draft exactly as before, and every visit after that renders the
pick-up-a-thread canvas that until now was only reachable with zero
projects.
---
apps/web/src/routes/_chat.index.tsx | 45 ++++++++++++++++++-----------
1 file changed, 28 insertions(+), 17 deletions(-)
diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx
index db3209be6..fcd8322c1 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 {
@@ -66,35 +66,44 @@ 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.
+ * Redirecting off `/` is something a launch does, not a rule about the route.
+ * Later trips home are deliberate ("Go to Home", the settings back button with
+ * no history left) and land on the home surface -- the pick-up-a-thread canvas
+ * -- instead of being bounced into a draft, so `/` only redirects once per app
+ * load.
*/
-let restoreAttempted = false;
+let launchVisitConsumed = 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 visit was consumed
+ // is a deliberate trip home and must never redirect.
+ const [isLaunchVisit] = useState(() => !launchVisitConsumed);
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 || launchVisitConsumed) {
+ 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;
}
+ launchVisitConsumed = true;
+ const entry = readLastVisitedThreadRoute(activeEnvironmentId);
const restored = resolveRestorableThreadRoute({
entry,
environmentId: activeEnvironmentId,
@@ -136,7 +145,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/")({
From efc3362f06c7ae6ed21b96aed0102335bf6ace7f Mon Sep 17 00:00:00 2001
From: badcuban <108198679+badcuban@users.noreply.github.com>
Date: Mon, 10 Aug 2026 00:48:23 -0400
Subject: [PATCH 2/2] Mark the launch moment from thread routes, not just the
index
The launch-vs-deliberate flag was only consumed inside the index route's
own redirect, but the launch does not necessarily pass through `/` at
all - the bootstrap welcome payload and a desktop deep link land
directly on a thread route. In those boots a later "Go to Home" was
mistaken for the launch visit and either bounced straight back to the
thread or stranded a blank canvas. Being on any thread or draft now
marks the launch moment consumed (via the route recorder, which already
watches exactly that), and the flag lives in its own module so both
routes can reach it.
Verified end to end in the dev harness: launch lands in the draft,
reload restores it, Go to Home shows the pick-up-a-thread canvas and
stays, New thread from Home reuses the empty draft, Home is repeatable.
---
apps/web/src/launchVisit.ts | 24 ++++++++++++++++++++++++
apps/web/src/routes/_chat.index.tsx | 22 ++++++++--------------
apps/web/src/routes/_chat.tsx | 5 +++++
3 files changed, 37 insertions(+), 14 deletions(-)
create mode 100644 apps/web/src/launchVisit.ts
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 fcd8322c1..8cb596751 100644
--- a/apps/web/src/routes/_chat.index.tsx
+++ b/apps/web/src/routes/_chat.index.tsx
@@ -20,6 +20,7 @@ import {
readLastVisitedThreadRoute,
resolveRestorableThreadRoute,
} from "../lastVisitedThreadRoute";
+import { isLaunchVisitConsumed, markLaunchVisitConsumed } from "../launchVisit";
import {
selectBootstrapCompleteForActiveEnvironment,
selectProjectsAcrossEnvironments,
@@ -65,15 +66,6 @@ function ChatIndexRouteView() {
}
}
-/**
- * Redirecting off `/` is something a launch does, not a rule about the route.
- * Later trips home are deliberate ("Go to Home", the settings back button with
- * no history left) and land on the home surface -- the pick-up-a-thread canvas
- * -- instead of being bounced into a draft, so `/` only redirects once per app
- * load.
- */
-let launchVisitConsumed = false;
-
/**
* What `/` resolves to once the workspace has loaded.
*
@@ -89,12 +81,14 @@ function DefaultProjectDraftRedirect() {
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 visit was consumed
- // is a deliberate trip home and must never redirect.
- const [isLaunchVisit] = useState(() => !launchVisitConsumed);
+ // 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(() => {
- if (!isLaunchVisit || launchVisitConsumed) {
+ if (!isLaunchVisit || isLaunchVisitConsumed()) {
return;
}
if (!activeEnvironmentId) {
@@ -102,7 +96,7 @@ function DefaultProjectDraftRedirect() {
// environment arrives, still counting as the launch visit.
return;
}
- launchVisitConsumed = true;
+ markLaunchVisitConsumed();
const entry = readLastVisitedThreadRoute(activeEnvironmentId);
const restored = resolveRestorableThreadRoute({
entry,
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,