Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/web/src/launchVisit.ts
Original file line number Diff line number Diff line change
@@ -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;
}
45 changes: 25 additions & 20 deletions apps/web/src/routes/_chat.index.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -20,6 +20,7 @@ import {
readLastVisitedThreadRoute,
resolveRestorableThreadRoute,
} from "../lastVisitedThreadRoute";
import { isLaunchVisitConsumed, markLaunchVisitConsumed } from "../launchVisit";
import {
selectBootstrapCompleteForActiveEnvironment,
selectProjectsAcrossEnvironments,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -136,7 +139,9 @@ function DefaultProjectDraftRedirect() {
openLastVisitedOrDefaultDraft();
}, [activeEnvironmentId, defaultProjectKey]);

return defaultProjectRef === null ? <NoActiveThreadState /> : 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 : <NoActiveThreadState />;
}

export const Route = createFileRoute("/_chat/")({
Expand Down
5 changes: 5 additions & 0 deletions apps/web/src/routes/_chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -127,13 +128,17 @@ 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),
});
return;
}
if (routeDraftId) {
markLaunchVisitConsumed();
recordLastVisitedThreadRoute(draftEnvironmentId ?? activeEnvironmentId, {
kind: "draft",
draftId: routeDraftId,
Expand Down
Loading