From f74db3c9585bb82c61c48f9fc8c1636359fe9664 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 8 Aug 2026 01:39:59 -0400 Subject: [PATCH 1/2] Fix desktop first-run gaps A fresh desktop launch bootstraps no project, so a cold start lands on the no-active-thread shell instead of a draft thread - and got none of the first-run guidance built for the npx door. Three fixes: - Setup card on the no-thread shell. useFirstRunSetupCard now owns the gate, project fallback, and actions for both empty canvases; the no-project surface leads with the amber "Choose a folder" row. Dismissal moved to a shared store so skipping on one surface settles both in the same tick. While the card owns the canvas the header reads "Set up Threadlines" instead of "No active thread". - The "Updates available" prompt waits while first-run setup is pending. Deferred, not consumed: the guard runs before the seen-key set, so the same prompt opens once setup completes or is skipped. Running/failed update progress is untouched. - Warm relaunch reopens the last thread or draft. A per-environment schema-validated localStorage record replaces the write-only lastChatThreadRef dead state; restore runs once per app load and is checked against live state, so deleted threads fall back cleanly and "Go to Home" never bounces back. Also extracts deriveDisplayProviderInstanceEntries, deduplicating the filter/sort/derive trio from ChatView and ChatComposer. --- apps/web/src/components/ChatView.tsx | 98 ++---- .../src/components/NoActiveThreadState.tsx | 283 ++++++++++-------- ...iderUpdateLaunchNotification.logic.test.ts | 30 ++ .../ProviderUpdateLaunchNotification.logic.ts | 28 ++ .../ProviderUpdateLaunchNotification.tsx | 19 +- apps/web/src/components/chat/ChatComposer.tsx | 9 +- .../chat/FirstRunSetupCard.browser.tsx | 27 ++ .../src/components/chat/FirstRunSetupCard.tsx | 188 +++++++----- .../src/components/chat/firstRunSetup.test.ts | 69 ++++- apps/web/src/components/chat/firstRunSetup.ts | 151 ++++++++-- .../src/components/chat/firstRunSetupState.ts | 51 ++++ apps/web/src/lastVisitedThreadRoute.test.ts | 115 +++++++ apps/web/src/lastVisitedThreadRoute.ts | 143 +++++++++ apps/web/src/providerInstances.ts | 14 + .../routes/_chat.$environmentId.$threadId.tsx | 6 - apps/web/src/routes/_chat.index.tsx | 78 ++++- apps/web/src/routes/_chat.tsx | 56 +++- apps/web/src/uiStateStore.test.ts | 18 +- apps/web/src/uiStateStore.ts | 25 +- 19 files changed, 1047 insertions(+), 361 deletions(-) create mode 100644 apps/web/src/components/chat/firstRunSetupState.ts create mode 100644 apps/web/src/lastVisitedThreadRoute.test.ts create mode 100644 apps/web/src/lastVisitedThreadRoute.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 74ab375e5..6262020f8 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -89,7 +89,6 @@ import { type PendingUserInputDraftAnswer, } from "../pendingUserInput"; import { - selectEnvironmentState, selectProjectsAcrossEnvironments, selectThreadsAcrossEnvironments, selectWorkspaceProjectsAcrossEnvironments, @@ -194,9 +193,7 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline, type TimelineProposedPlanState } from "./chat/MessagesTimeline"; import { DraftEmptyState } from "./chat/DraftEmptyState"; -import { FirstRunSetupCard, useFirstRunSetupDismissal } from "./chat/FirstRunSetupCard"; -import { shouldShowFirstRunSetupCard } from "./chat/firstRunSetup"; -import { isHostedStaticApp } from "../hostedPairing"; +import { useFirstRunSetupCard } from "./chat/FirstRunSetupCard"; import { ProviderModelPicker } from "./chat/ProviderModelPicker"; import { ChatHeader, type ForkHeaderContext } from "./chat/ChatHeader"; import type { DesktopPreviewPickedElement } from "@threadlines/contracts"; @@ -315,9 +312,7 @@ import { } from "../versionSkew"; import { derivePlanTaskBadge, useThreadPlanCatalog } from "../planPanelState"; import { - deriveProviderInstanceEntries, - filterMaintainedProviderInstanceEntries, - sortProviderInstanceEntries, + deriveDisplayProviderInstanceEntries, type ProviderInstanceEntry, } from "../providerInstances"; @@ -1758,10 +1753,7 @@ export default function ChatView(props: ChatViewProps) { const providerStatusesRef = useRef(providerStatuses); providerStatusesRef.current = providerStatuses; const providerInstanceEntries = useMemo>( - () => - filterMaintainedProviderInstanceEntries( - sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), - ), + () => deriveDisplayProviderInstanceEntries(providerStatuses), [providerStatuses], ); const modelOptionsByInstance = useMemo(() => { @@ -3395,67 +3387,31 @@ export default function ChatView(props: ChatViewProps) { // --- First-run setup card ------------------------------------------------- // A cold install has no threads and no guidance, so the draft thread's empty // state becomes the setup checklist until the user sends something or skips. - // Hosted phone/browser sessions are excluded: they have their own pairing - // states and no local provider they could sign in to from here. - const isHostedStaticSurface = useMemo(() => isHostedStaticApp(), []); - const { isDismissed: isFirstRunSetupDismissed, dismiss: dismissFirstRunSetupForEnvironment } = - useFirstRunSetupDismissal(draftThread?.environmentId ?? environmentId); - const hasUserMessagedThread = useStore((state) => { - const environmentState = selectEnvironmentState(state, environmentId); - return environmentState.threadIds.some( - (candidateThreadId) => - environmentState.sidebarThreadSummaryById[candidateThreadId]?.latestUserMessageAt != null, - ); - }); - const isEnvironmentBootstrapComplete = useStore( - (state) => selectEnvironmentState(state, environmentId).bootstrapComplete, - ); - const showFirstRunSetupCard = shouldShowFirstRunSetupCard({ - isHostedStatic: isHostedStaticSurface, - isDraftThread: isLocalDraftThread && draftThread !== undefined, - isGeneralChat: isGeneralChatThread, - bootstrapComplete: isEnvironmentBootstrapComplete, - hasUserMessagedThread, - isDismissed: isFirstRunSetupDismissed, + // The same card renders on the no-active-thread shell (see + // `NoActiveThreadState`), which is where a desktop launch with no project + // lands; the shared hook keeps the gate and the dismissal identical there. + // + // A reloaded draft thread has no project bound yet (`activeProject` is null + // until the first send); the hook falls back to the workspace's first + // project so the card cannot claim "No folder yet" while the sidebar shows + // one. General Chat can't be the active project here because the card never + // renders on General Chat drafts. + const { + isVisible: showFirstRunSetupCard, + card: firstRunSetupCard, + dismiss: dismissFirstRunSetupForEnvironment, + } = useFirstRunSetupCard({ + surface: { + kind: "draftThread", + isDraftThread: isLocalDraftThread && draftThread !== undefined, + isGeneralChat: isGeneralChatThread, + }, + environmentId: draftThread?.environmentId ?? environmentId, + providers: providerInstanceEntries, + activeProject: activeProject ?? null, + onStart: scheduleComposerFocus, }); - const firstRunWorkspaceProjects = useMemo( - () => allProjects.filter((project) => project.kind !== "general-chat"), - [allProjects], - ); - // A reloaded draft thread has no project bound yet (`activeProject` is - // null until the first send), but the bootstrapped workspace project is - // already in the project list; the card must not claim "No folder yet" - // while the sidebar shows one. General Chat can't be the active project - // here because the card never renders on General Chat drafts. - const firstRunProject = activeProject ?? firstRunWorkspaceProjects[0] ?? null; - const firstRunSetupEmptyState = useMemo(() => { - if (!showFirstRunSetupCard) { - return undefined; - } - return ( - useCommandPaletteStore.getState().openAddProject()} - onSkip={dismissFirstRunSetupForEnvironment} - onStart={() => { - dismissFirstRunSetupForEnvironment(); - scheduleComposerFocus(); - }} - /> - ); - }, [ - dismissFirstRunSetupForEnvironment, - environmentId, - firstRunProject, - firstRunWorkspaceProjects.length, - providerInstanceEntries, - scheduleComposerFocus, - showFirstRunSetupCard, - ]); + const firstRunSetupEmptyState = firstRunSetupCard ?? undefined; const runMcpAuthReconnect = useCallback( async (action: McpAuthReconnectAction) => { diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx index 3ea511a0b..089b0462e 100644 --- a/apps/web/src/components/NoActiveThreadState.tsx +++ b/apps/web/src/components/NoActiveThreadState.tsx @@ -20,11 +20,14 @@ import { startNewThreadInProjectFromContext, } from "../lib/chatThreadActions"; import { resolveGeneralChatsProjectRef } from "../lib/generalChats"; +import { deriveDisplayProviderInstanceEntries } from "../providerInstances"; +import { useServerProviders } from "../rpc/serverState"; import { selectGeneralChatsProjectAcrossEnvironments, selectSidebarThreadsAcrossEnvironments, useStore, } from "../store"; +import { useFirstRunSetupCard } from "./chat/FirstRunSetupCard"; import { ProjectFavicon } from "./ProjectFavicon"; import { RecentThreadsList } from "./RecentThreadsList"; import { resolveSidebarNewThreadEnvMode } from "./Sidebar.logic"; @@ -96,6 +99,28 @@ export function NoActiveThreadState() { } void startNewGeneralChatThread(handleNewThread, generalChatsRef); }; + + // A desktop launch bootstraps no project from a launch folder the way `npx` + // does, so a cold start lands here rather than on a draft thread. Same card, + // same gate, same dismissal as the draft canvas -- only the project row + // differs, leading in its amber "Choose a folder" state. + const providerStatuses = useServerProviders(); + const providerInstanceEntries = useMemo( + () => deriveDisplayProviderInstanceEntries(providerStatuses), + [providerStatuses], + ); + const { card: firstRunSetupCard } = useFirstRunSetupCard({ + surface: { kind: "noThread" }, + environmentId: activeEnvironmentId, + providers: providerInstanceEntries, + activeProject: null, + onStart: handleNewThreadClick, + }); + + // "No active thread" is meaningless jargon to a first run that has never + // seen a thread; while the setup card owns the canvas the title echoes it. + const headerLabel = firstRunSetupCard ? "Set up Threadlines" : "No active thread"; + return (
@@ -115,158 +140,160 @@ export function NoActiveThreadState() {
- No active thread + {headerLabel}
) : (
- No active thread + {headerLabel}
)} -
- + {firstRunSetupCard ?? ( +
+ - - {hasProject ? "Pick up a thread" : "Start your first thread"} - - - {hasProject - ? hasRecentThreads - ? "Jump back in, or start something new." - : "Resume one from the sidebar, or start fresh." - : generalChatsRef - ? "Start a general chat, or add a project for repo-aware work." - : "Add a project to begin."} - + + {hasProject ? "Pick up a thread" : "Start your first thread"} + + + {hasProject + ? hasRecentThreads + ? "Jump back in, or start something new." + : "Resume one from the sidebar, or start fresh." + : generalChatsRef + ? "Start a general chat, or add a project for repo-aware work." + : "Add a project to begin."} + -
- {hasProject ? ( - - - {orderedProjects.length > 1 || generalChatsRef ? ( - - - } - > - - - - {generalChatsRef ? ( - <> - + ) : null} + + + {orderedProjects.length > 1 || generalChatsRef ? ( + + + } + > + + + + {generalChatsRef ? ( + <> + + + + General chat + + + + + ) : null} + + New thread in… + {orderedProjects.map((project) => ( handleNewThreadInProject(project)} + title={project.cwd} > - - General chat + + {project.name} - - - - ) : null} - - New thread in… - {orderedProjects.map((project) => ( - handleNewThreadInProject(project)} - title={project.cwd} - > - - {project.name} - - ))} - - - - ) : null} - - ) : ( - <> - {generalChatsRef ? ( + ))} + + + + ) : null} + + ) : ( + <> + {generalChatsRef ? ( + + ) : null} - ) : null} - - - )} - -
+ + )} + +
- -
+ +
+ )}
diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts index 61c4317d6..e93471b1f 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.test.ts @@ -19,6 +19,7 @@ import { hasOneClickUpdateProviderCandidate, isProviderUpdateCandidate, providerUpdateNotificationKey, + shouldOpenProviderUpdatePrompt, type ProviderUpdateCandidate, } from "./ProviderUpdateLaunchNotification.logic"; @@ -238,6 +239,35 @@ describe("provider update launch notification logic", () => { ); }); + describe("shouldOpenProviderUpdatePrompt", () => { + const OPENABLE = { + notificationKey: "codex:1.1.0", + isDismissed: false, + isAlreadySeen: false, + hasActiveToast: false, + isFirstRunSetupPending: false, + } as const; + + it("opens on launch when there is an update nobody has been told about", () => { + expect(shouldOpenProviderUpdatePrompt(OPENABLE)).toBe(true); + expect(shouldOpenProviderUpdatePrompt({ ...OPENABLE, notificationKey: null })).toBe(false); + expect(shouldOpenProviderUpdatePrompt({ ...OPENABLE, isDismissed: true })).toBe(false); + expect(shouldOpenProviderUpdatePrompt({ ...OPENABLE, isAlreadySeen: true })).toBe(false); + expect(shouldOpenProviderUpdatePrompt({ ...OPENABLE, hasActiveToast: true })).toBe(false); + }); + + it("waits while first-run setup is on screen, then opens once setup is done", () => { + expect(shouldOpenProviderUpdatePrompt({ ...OPENABLE, isFirstRunSetupPending: true })).toBe( + false, + ); + // Deferred, not dropped: the same key still opens once setup is + // completed or skipped, because nothing marked it seen while it waited. + expect(shouldOpenProviderUpdatePrompt({ ...OPENABLE, isFirstRunSetupPending: false })).toBe( + true, + ); + }); + }); + it("tracks updated provider snapshots by instance instead of collapsing to a sibling driver", () => { const targetInstanceId = instanceId("codex_personal"); const siblingInstanceId = instanceId("codex"); diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts index 4777472ba..57c7fb36d 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.logic.ts @@ -221,6 +221,34 @@ export function providerUpdateNotificationKey( return parts.length > 0 ? parts.join("|") : null; } +/** + * Whether the launch prompt ("Updates available") may open right now. + * + * The last clause is the interesting one: while first-run setup is on screen + * the user is being told what to install and sign in to, and a toast offering + * a different install on top of that is the stacking noise the composer notice + * dock already refuses. The prompt is deferred, not dropped -- once setup is + * completed or skipped this turns true and the prompt opens then. Only this + * informational prompt waits; a running or failed update still reports itself, + * because that is progress the user asked for. + */ +export function shouldOpenProviderUpdatePrompt(input: { + readonly notificationKey: string | null; + readonly isDismissed: boolean; + /** Already shown once this session, whether or not it is still on screen. */ + readonly isAlreadySeen: boolean; + readonly hasActiveToast: boolean; + readonly isFirstRunSetupPending: boolean; +}): boolean { + return ( + input.notificationKey !== null && + !input.isDismissed && + !input.isAlreadySeen && + !input.hasActiveToast && + !input.isFirstRunSetupPending + ); +} + export function providerUpdateCandidateKey(provider: ProviderUpdateCandidate): string { return providerUpdateNotificationKey([provider])!; } diff --git a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx index 4f09a5af9..43c5bccfd 100644 --- a/apps/web/src/components/ProviderUpdateLaunchNotification.tsx +++ b/apps/web/src/components/ProviderUpdateLaunchNotification.tsx @@ -17,8 +17,10 @@ import { getProviderUpdateRejectedToastView, getProviderUpdateRunningToastView, providerUpdateNotificationKey, + shouldOpenProviderUpdatePrompt, type ProviderUpdateToastView, } from "./ProviderUpdateLaunchNotification.logic"; +import { useActiveEnvironmentFirstRunSetupPending } from "./chat/firstRunSetupState"; import { stackedThreadToast, toastManager } from "./ui/toast"; const seenProviderUpdateNotificationKeys = new Set(); @@ -163,6 +165,8 @@ export function ProviderUpdateLaunchNotification() { const activeToastRef = useRef(null); const { dismissedNotificationKeys, dismissNotificationKey } = useDismissedProviderUpdateNotificationKeys(); + // First-run setup owns the screen while it is up; this prompt waits for it. + const firstRunSetupPending = useActiveEnvironmentFirstRunSetupPending(); const updateProviders = useMemo(() => collectProviderUpdateCandidates(providers), [providers]); const notificationKey = useMemo( @@ -223,11 +227,17 @@ export function ProviderUpdateLaunchNotification() { activeToastRef.current = null; } + if (notificationKey === null) { + return; + } if ( - !notificationKey || - dismissedNotificationKeys.has(notificationKey) || - seenProviderUpdateNotificationKeys.has(notificationKey) || - activeToastRef.current + !shouldOpenProviderUpdatePrompt({ + notificationKey, + isDismissed: dismissedNotificationKeys.has(notificationKey), + isAlreadySeen: seenProviderUpdateNotificationKeys.has(notificationKey), + hasActiveToast: activeToastRef.current !== null, + isFirstRunSetupPending: firstRunSetupPending, + }) ) { return; } @@ -354,6 +364,7 @@ export function ProviderUpdateLaunchNotification() { }, [ dismissNotificationKey, dismissedNotificationKeys, + firstRunSetupPending, notificationKey, oneClickProviders, openProviderSettings, diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 792cc2b89..ff75b1663 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -147,10 +147,8 @@ import { type RuntimeModeOption, } from "../../runtimeModeOptions"; import { - deriveProviderInstanceEntries, - filterMaintainedProviderInstanceEntries, + deriveDisplayProviderInstanceEntries, resolveProviderDriverKindForInstanceSelection, - sortProviderInstanceEntries, type ProviderInstanceEntry, } from "../../providerInstances"; import { type AppModelOption, getAppModelOptionsForInstance } from "../../modelSelection"; @@ -814,10 +812,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // configured instance (default built-in + any custom `providerInstances.*`), // sorted default-first per driver kind for a stable picker order. const providerInstanceEntries = useMemo>( - () => - filterMaintainedProviderInstanceEntries( - sortProviderInstanceEntries(deriveProviderInstanceEntries(providerStatuses)), - ), + () => deriveDisplayProviderInstanceEntries(providerStatuses), [providerStatuses], ); const selectedProviderByThreadId = composerDraft.activeProvider ?? null; diff --git a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx index 3d61ac1fa..ac1de0335 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.browser.tsx @@ -445,4 +445,31 @@ describe("FirstRunSetupCard", () => { await screen.unmount(); }); + + it("leads with the folder row on a launch that bootstrapped no project", async () => { + // What a desktop cold start looks like: nothing to work in, so the amber + // folder row is the first thing on the checklist rather than the last. + const screen = await renderCard({ + providers: [SIGNED_IN_CLAUDE, SIGNED_OUT_CODEX], + projectName: null, + }); + + expect(Object.keys(rowStates())).toEqual(["project", "codex", "claudeAgent"]); + const projectDot = document.querySelector("[data-testid='first-run-setup-dot']"); + expect(projectDot?.getAttribute("data-row-state")).toBe("missing"); + expect(projectDot?.className).toContain("bg-warning"); + + await screen.unmount(); + }); + + it("puts a folder that already exists back at the end of the checklist", async () => { + const screen = await renderCard({ + providers: [SIGNED_OUT_CODEX], + projectName: "B-git-project", + }); + + expect(Object.keys(rowStates())).toEqual(["codex", "project"]); + + await screen.unmount(); + }); }); diff --git a/apps/web/src/components/chat/FirstRunSetupCard.tsx b/apps/web/src/components/chat/FirstRunSetupCard.tsx index 198f43d65..af7ee575d 100644 --- a/apps/web/src/components/chat/FirstRunSetupCard.tsx +++ b/apps/web/src/components/chat/FirstRunSetupCard.tsx @@ -1,10 +1,12 @@ /** - * The first thing a brand-new install sees, in place of the empty draft - * thread's usual "What's next in ...?" prompt. + * The first thing a brand-new install sees, in place of whichever empty canvas + * it landed on: the draft thread's "What's next in ...?" prompt, or the + * no-active-thread shell a desktop launch with no project starts from. * * It is the same provider data the settings page shows, with the fix action on * the row instead of two clicks away, plus the folder the server bootstrapped - * from. Rows are live: provider snapshots stream in over providers-updated + * from (or the amber "Choose a folder" row when there is none). Rows are live: + * provider snapshots stream in over providers-updated * events, so a dot flips from amber to green as soon as a sign-in lands, and * "Start first thread" enables at the same moment. * @@ -24,9 +26,12 @@ */ import type { EnvironmentId } from "@threadlines/contracts"; import { Link } from "@tanstack/react-router"; -import { useCallback, useMemo, useState, type ReactNode } from "react"; +import { useMemo, type ReactNode } from "react"; +import { useShallow } from "zustand/react/shallow"; +import { useCommandPaletteStore } from "../../commandPaletteStore"; import { cn } from "../../lib/utils"; +import { selectWorkspaceProjectsAcrossEnvironments, useStore } from "../../store"; import { ProjectFavicon } from "../ProjectFavicon"; import { ProviderInstallAction } from "../settings/ProviderInstallAction"; import { useProviderConnectFlow } from "../settings/useProviderConnectFlow"; @@ -38,53 +43,17 @@ import { toProviderSignInFlowView, } from "./providerSignIn"; import { - buildFirstRunSetupDismissalKey, canStartFirstThread, deriveFirstRunProjectRow, deriveFirstRunProviderRows, - dismissFirstRunSetup, groupFirstRunProviderRows, - isFirstRunSetupDismissed, + isFirstRunSetupSurfaceEligible, + useFirstRunSetupDismissal, type FirstRunProviderRow, type FirstRunSetupProvider, + type FirstRunSetupSurface, } from "./firstRunSetup"; - -/** - * Reads the environment's dismissal once and re-reads it after this hook - * writes one, so "Skip for now" hides the card in the same tick. Callers that - * dismiss on another path (a send) go through the returned `dismiss` too, - * which keeps the render in step with storage. - */ -export function useFirstRunSetupDismissal(environmentId: EnvironmentId | null | undefined): { - readonly isDismissed: boolean; - readonly dismiss: () => void; -} { - const dismissalKey = environmentId ? buildFirstRunSetupDismissalKey(environmentId) : null; - const [dismissedKeys, setDismissedKeys] = useState>(() => - dismissalKey !== null && isFirstRunSetupDismissed(dismissalKey) ? [dismissalKey] : [], - ); - - const dismiss = useCallback(() => { - if (dismissalKey === null) { - return; - } - dismissFirstRunSetup(dismissalKey); - setDismissedKeys((current) => - current.includes(dismissalKey) ? current : [...current, dismissalKey], - ); - }, [dismissalKey]); - - // Re-read storage when the key changes (environment switch) rather than on - // every render: this hook lives in the chat view's render path. - const isDismissed = useMemo( - () => - dismissalKey !== null && - (dismissedKeys.includes(dismissalKey) || isFirstRunSetupDismissed(dismissalKey)), - [dismissalKey, dismissedKeys], - ); - - return { isDismissed, dismiss }; -} +import { useFirstRunSetupPending } from "./firstRunSetupState"; function SetupRow({ rowId, @@ -216,6 +185,37 @@ export function FirstRunSetupCard({ projectRow.description ); + const projectSetupRow = ( + + {projectRow.actionLabel} + + ) : ( + + ) + } + /> + ); + // Rows are ordered by what the user has to do next, which is why the agents + // sort by actionability. A folder that is already there is confirmation and + // sits at the end; a missing one is the whole reason this canvas is empty, + // so it leads. + const projectRowLeads = projectRow.state === "missing"; + return (
    + {projectRowLeads ? projectSetupRow : null} {providerRowGroups.visible.map((row) => ( ) : null} - - {projectRow.actionLabel} - - ) : ( - - ) - } - /> + {projectRowLeads ? null : projectSetupRow}
); } + +/** The project a surface is already bound to, if it has one. */ +export interface FirstRunSetupActiveProject { + readonly name: string; + readonly cwd: string; + readonly environmentId: EnvironmentId; +} + +export interface UseFirstRunSetupCardInput { + readonly surface: FirstRunSetupSurface; + /** The environment whose first run this is. */ + readonly environmentId: EnvironmentId | null | undefined; + /** Provider instances for that environment, in the order Settings lists them. */ + readonly providers: ReadonlyArray; + /** + * The project this surface already has. A reloaded draft has none bound yet, + * and the no-thread canvas never does; both fall back to the workspace's + * first project so the card cannot claim "No folder yet" while the sidebar + * shows one. + */ + readonly activeProject: FirstRunSetupActiveProject | null; + /** Runs after "Start first thread" completes setup. */ + readonly onStart: () => void; +} + +export interface FirstRunSetupCardState { + /** True when the card owns this surface's empty state. */ + readonly isVisible: boolean; + /** The card, or null when it does not belong here. */ + readonly card: ReactNode | null; + /** Completes setup for this environment on every surface at once. */ + readonly dismiss: () => void; +} + +/** + * Assembles the setup card for one surface: the gate, the environment's + * provider and project data, and the actions. + * + * Lives here rather than in either caller because both empty canvases -- the + * draft thread and the no-active-thread shell -- need the identical card under + * the identical gate, and a fresh desktop launch reaches only the second one. + */ +export function useFirstRunSetupCard(input: UseFirstRunSetupCardInput): FirstRunSetupCardState { + const { activeProject, environmentId, onStart, providers, surface } = input; + const isPending = useFirstRunSetupPending(environmentId); + const { dismiss } = useFirstRunSetupDismissal(environmentId); + const isVisible = isPending && isFirstRunSetupSurfaceEligible(surface); + + const workspaceProjects = useStore(useShallow(selectWorkspaceProjectsAcrossEnvironments)); + const project = activeProject ?? workspaceProjects[0] ?? null; + const isOnlyWorkspaceProject = workspaceProjects.length === 1; + + const card = useMemo(() => { + if (!isVisible) { + return null; + } + return ( + useCommandPaletteStore.getState().openAddProject()} + onSkip={dismiss} + onStart={() => { + dismiss(); + onStart(); + }} + /> + ); + }, [dismiss, environmentId, isOnlyWorkspaceProject, isVisible, onStart, project, providers]); + + return { isVisible, card, dismiss }; +} diff --git a/apps/web/src/components/chat/firstRunSetup.test.ts b/apps/web/src/components/chat/firstRunSetup.test.ts index 7d35fa95b..250132a6c 100644 --- a/apps/web/src/components/chat/firstRunSetup.test.ts +++ b/apps/web/src/components/chat/firstRunSetup.test.ts @@ -16,8 +16,11 @@ import { FIRST_RUN_SETUP_DISMISSALS_STORAGE_KEY, groupFirstRunProviderRows, isFirstRunSetupDismissed, + resetFirstRunSetupDismissalsForTests, shouldShowFirstRunSetupCard, + useFirstRunSetupDismissalStore, type FirstRunSetupProvider, + type FirstRunSetupSurface, } from "./firstRunSetup"; function provider(overrides: { @@ -108,10 +111,16 @@ const MISSING_PROJECT_ROW = deriveFirstRunProjectRow({ isOnlyWorkspaceProject: false, }); -const SHOWN_INPUT = { - isHostedStatic: false, +const DRAFT_SURFACE = { + kind: "draftThread", isDraftThread: true, isGeneralChat: false, +} as const satisfies FirstRunSetupSurface; +const NO_THREAD_SURFACE = { kind: "noThread" } as const satisfies FirstRunSetupSurface; + +const SHOWN_INPUT = { + surface: DRAFT_SURFACE, + isHostedStatic: false, bootstrapComplete: true, hasUserMessagedThread: false, isDismissed: false, @@ -122,20 +131,48 @@ describe("shouldShowFirstRunSetupCard", () => { expect(shouldShowFirstRunSetupCard(SHOWN_INPUT)).toBe(true); }); + it("shows on the no-thread canvas a project-less desktop launch lands on", () => { + expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, surface: NO_THREAD_SURFACE })).toBe(true); + }); + it("hides once any thread in the environment carries a user message", () => { expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, hasUserMessagedThread: true })).toBe( false, ); }); - it("hides once dismissed", () => { + it("hides once dismissed, on either canvas", () => { expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, isDismissed: true })).toBe(false); + expect( + shouldShowFirstRunSetupCard({ + ...SHOWN_INPUT, + surface: NO_THREAD_SURFACE, + isDismissed: true, + }), + ).toBe(false); }); it("never renders on hosted phone surfaces, general chat, or a server thread", () => { expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, isHostedStatic: true })).toBe(false); - expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, isGeneralChat: true })).toBe(false); - expect(shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, isDraftThread: false })).toBe(false); + expect( + shouldShowFirstRunSetupCard({ + ...SHOWN_INPUT, + surface: NO_THREAD_SURFACE, + isHostedStatic: true, + }), + ).toBe(false); + expect( + shouldShowFirstRunSetupCard({ + ...SHOWN_INPUT, + surface: { ...DRAFT_SURFACE, isGeneralChat: true }, + }), + ).toBe(false); + expect( + shouldShowFirstRunSetupCard({ + ...SHOWN_INPUT, + surface: { ...DRAFT_SURFACE, isDraftThread: false }, + }), + ).toBe(false); }); it("waits for the environment bootstrap before deciding", () => { @@ -146,6 +183,7 @@ describe("shouldShowFirstRunSetupCard", () => { describe("first-run setup dismissal", () => { beforeEach(() => { removeLocalStorageItem(FIRST_RUN_SETUP_DISMISSALS_STORAGE_KEY); + resetFirstRunSetupDismissalsForTests(); }); it("persists per environment", () => { @@ -159,6 +197,27 @@ describe("first-run setup dismissal", () => { expect(isFirstRunSetupDismissed(local)).toBe(true); expect(isFirstRunSetupDismissed(remote)).toBe(false); }); + + it("is shared: skipping on one surface is skipping on every surface", () => { + const local = buildFirstRunSetupDismissalKey(EnvironmentId.make("environment-local")); + + // The draft canvas, the no-thread canvas, and the update prompt all read + // this store, so one write has to settle it for all of them in the same + // tick -- not only after the next reload. + useFirstRunSetupDismissalStore.getState().dismiss(local); + + expect(useFirstRunSetupDismissalStore.getState().dismissedKeys.has(local)).toBe(true); + expect( + shouldShowFirstRunSetupCard({ ...SHOWN_INPUT, isDismissed: isFirstRunSetupDismissed(local) }), + ).toBe(false); + expect( + shouldShowFirstRunSetupCard({ + ...SHOWN_INPUT, + surface: NO_THREAD_SURFACE, + isDismissed: isFirstRunSetupDismissed(local), + }), + ).toBe(false); + }); }); describe("deriveFirstRunProviderRows", () => { diff --git a/apps/web/src/components/chat/firstRunSetup.ts b/apps/web/src/components/chat/firstRunSetup.ts index d9858d23e..88c7ab00c 100644 --- a/apps/web/src/components/chat/firstRunSetup.ts +++ b/apps/web/src/components/chat/firstRunSetup.ts @@ -1,12 +1,17 @@ /** * First-run setup card: what it says, when it shows, and when it stops. * - * A brand-new install lands on an empty draft thread with no guidance, even - * though the client already holds everything needed to tell the user what is - * missing: the provider snapshots Settings renders, and the project the server - * bootstrapped from the launch folder. This module turns those into the rows - * the card draws, decides whether the card belongs on screen at all, and owns - * the per-environment dismissal record. + * A brand-new install lands on an empty canvas with no guidance, even though + * the client already holds everything needed to tell the user what is missing: + * the provider snapshots Settings renders, and the project the server + * bootstrapped from the launch folder (if any). This module turns those into + * the rows the card draws, decides whether the card belongs on screen at all, + * and owns the per-environment dismissal record. + * + * Which canvas depends on how Threadlines was started: `npx` bootstraps a + * project from the launch folder and lands on a draft thread, while a fresh + * desktop launch has no project and lands on the no-active-thread surface. See + * `FirstRunSetupSurface`. * * Status language is borrowed from the settings page (`getProviderSummary`) * and the availability verdict from the model picker @@ -23,6 +28,8 @@ import type { } from "@threadlines/contracts"; import { providerAuthReconnectCommand } from "@threadlines/shared/providerAuth"; import * as Schema from "effect/Schema"; +import { useMemo } from "react"; +import { create } from "zustand"; import { getLocalStorageItemWithLegacyKeys, @@ -86,48 +93,144 @@ export function isFirstRunSetupDismissed(dismissalKey: string | null | undefined if (!dismissalKey) { return false; } - return readFirstRunSetupDismissals().keys.includes(dismissalKey); + return ( + useFirstRunSetupDismissalStore.getState().dismissedKeys.has(dismissalKey) || + readFirstRunSetupDismissals().keys.includes(dismissalKey) + ); +} + +/** + * The dismissal record, live. + * + * Storage alone is not enough: the card renders on two surfaces and the + * provider-update prompt reads the same verdict, so "Skip for now" has to + * reach every one of them in the same tick. A store does that; per-component + * state would leave one surface showing a card the user just dismissed on + * another. + */ +interface FirstRunSetupDismissalStore { + readonly dismissedKeys: ReadonlySet; + readonly dismiss: (dismissalKey: string) => void; } +export const useFirstRunSetupDismissalStore = create((set, get) => ({ + dismissedKeys: new Set(readFirstRunSetupDismissals().keys), + dismiss: (dismissalKey) => { + if (get().dismissedKeys.has(dismissalKey)) { + return; + } + const document = readFirstRunSetupDismissals(); + if (!document.keys.includes(dismissalKey)) { + writeFirstRunSetupDismissals({ keys: [...document.keys, dismissalKey] }); + } + set((state) => ({ dismissedKeys: new Set(state.dismissedKeys).add(dismissalKey) })); + }, +})); + export function dismissFirstRunSetup(dismissalKey: string | null | undefined): void { if (!dismissalKey) { return; } - const document = readFirstRunSetupDismissals(); - if (document.keys.includes(dismissalKey)) { - return; - } - writeFirstRunSetupDismissals({ keys: [...document.keys, dismissalKey] }); + useFirstRunSetupDismissalStore.getState().dismiss(dismissalKey); +} + +/** Test seam: forgets in-memory dismissals so a suite can start from a cold install. */ +export function resetFirstRunSetupDismissalsForTests(): void { + useFirstRunSetupDismissalStore.setState({ + dismissedKeys: new Set(readFirstRunSetupDismissals().keys), + }); +} + +/** + * Reads this environment's dismissal, and writes one. Every caller shares the + * store behind it, so skipping on one surface hides the card on all of them. + */ +export function useFirstRunSetupDismissal(environmentId: EnvironmentId | null | undefined): { + readonly isDismissed: boolean; + readonly dismiss: () => void; +} { + const dismissalKey = environmentId ? buildFirstRunSetupDismissalKey(environmentId) : null; + const isDismissed = useFirstRunSetupDismissalStore((store) => + dismissalKey === null ? false : store.dismissedKeys.has(dismissalKey), + ); + const dismissKey = useFirstRunSetupDismissalStore((store) => store.dismiss); + + return useMemo( + () => ({ + isDismissed, + dismiss: () => { + if (dismissalKey !== null) { + dismissKey(dismissalKey); + } + }, + }), + [dismissKey, dismissalKey, isDismissed], + ); +} + +/** + * The two empty canvases a cold start can land on. + * + * `npx` bootstraps a project from the launch folder, so it always lands on a + * draft thread. A fresh desktop launch has no project at all and lands on the + * no-active-thread canvas instead, which is why the card has to cover both. + */ +export type FirstRunSetupSurface = + /** The draft thread's empty state, in place of "What's next in ...?". */ + | { + readonly kind: "draftThread"; + readonly isDraftThread: boolean; + readonly isGeneralChat: boolean; + } + /** The no-active-thread canvas, in place of "Start your first thread". */ + | { readonly kind: "noThread" }; + +/** + * Whether this surface is a place the card belongs. A server thread is not + * (the user is reading a conversation), and neither is a General Chat draft, + * which has no project row to speak of. + */ +export function isFirstRunSetupSurfaceEligible(surface: FirstRunSetupSurface): boolean { + return surface.kind === "noThread" || (surface.isDraftThread && !surface.isGeneralChat); } /** - * The card takes over the draft thread's empty state only on a genuine cold - * start. + * Whether this environment is still in its first run, independent of which + * surface is on screen. + * + * Every clause is a reason setup would be noise: a hosted phone has its own + * pairing surface and no local provider to fix; an environment still + * bootstrapping has not told us whether it has threads; an environment where + * someone has already sent a message is not a first run; and a dismissal is + * permanent. * - * Every clause is a reason the card would be noise: a hosted phone has its own - * pairing surface and no local provider to fix; General Chat has no project - * row to speak of; an environment still bootstrapping has not told us whether - * it has threads; an environment where someone has already sent a message is - * not a first run; and a dismissal is permanent. + * Split out from `shouldShowFirstRunSetupCard` because things that must stay + * quiet during setup (the provider-update prompt) need the verdict without + * caring which canvas the card is drawn on. */ -export function shouldShowFirstRunSetupCard(input: { +export function isFirstRunSetupPending(input: { readonly isHostedStatic: boolean; - readonly isDraftThread: boolean; - readonly isGeneralChat: boolean; readonly bootstrapComplete: boolean; readonly hasUserMessagedThread: boolean; readonly isDismissed: boolean; }): boolean { return ( - input.isDraftThread && !input.isHostedStatic && - !input.isGeneralChat && input.bootstrapComplete && !input.hasUserMessagedThread && !input.isDismissed ); } +/** The card takes over an eligible empty canvas on a genuine cold start. */ +export function shouldShowFirstRunSetupCard( + input: { + readonly surface: FirstRunSetupSurface; + } & Parameters[0], +): boolean { + return isFirstRunSetupSurfaceEligible(input.surface) && isFirstRunSetupPending(input); +} + /** What the user has to do next about one provider, if anything. */ export type FirstRunProviderRowState = "ready" | "needsSignIn" | "notInstalled"; diff --git a/apps/web/src/components/chat/firstRunSetupState.ts b/apps/web/src/components/chat/firstRunSetupState.ts new file mode 100644 index 000000000..3ff9c9b40 --- /dev/null +++ b/apps/web/src/components/chat/firstRunSetupState.ts @@ -0,0 +1,51 @@ +/** + * "Is this environment still being set up?", answered from live app state. + * + * `firstRunSetup.ts` owns the rules and the dismissal record; this module + * feeds them the environment's bootstrap and message history. It exists as its + * own module because the answer is needed outside the chat view: the + * provider-update prompt has to stay quiet until setup is done, and it renders + * at the app root with none of the chat view's state in reach. + * + * @module firstRunSetupState + */ +import type { EnvironmentId } from "@threadlines/contracts"; +import { useMemo } from "react"; + +import { isHostedStaticApp } from "../../hostedPairing"; +import { selectEnvironmentState, useStore } from "../../store"; +import { isFirstRunSetupPending, useFirstRunSetupDismissal } from "./firstRunSetup"; + +/** + * Whether the given environment is still in its first run: setup has not been + * completed, skipped, or ruled out. True regardless of which surface is on + * screen, so callers that only need "is the user still being onboarded" do not + * have to reach into chat state. + */ +export function useFirstRunSetupPending(environmentId: EnvironmentId | null | undefined): boolean { + const isHostedStatic = useMemo(() => isHostedStaticApp(), []); + const { isDismissed } = useFirstRunSetupDismissal(environmentId); + const bootstrapComplete = useStore( + (state) => selectEnvironmentState(state, environmentId).bootstrapComplete, + ); + const hasUserMessagedThread = useStore((state) => { + const environmentState = selectEnvironmentState(state, environmentId); + return environmentState.threadIds.some( + (threadId) => + environmentState.sidebarThreadSummaryById[threadId]?.latestUserMessageAt != null, + ); + }); + + return isFirstRunSetupPending({ + isHostedStatic, + bootstrapComplete, + hasUserMessagedThread, + isDismissed, + }); +} + +/** `useFirstRunSetupPending` for the environment the shell is pointed at. */ +export function useActiveEnvironmentFirstRunSetupPending(): boolean { + const activeEnvironmentId = useStore((state) => state.activeEnvironmentId); + return useFirstRunSetupPending(activeEnvironmentId); +} diff --git a/apps/web/src/lastVisitedThreadRoute.test.ts b/apps/web/src/lastVisitedThreadRoute.test.ts new file mode 100644 index 000000000..5e0635848 --- /dev/null +++ b/apps/web/src/lastVisitedThreadRoute.test.ts @@ -0,0 +1,115 @@ +import { EnvironmentId, ThreadId } from "@threadlines/contracts"; +import * as Schema from "effect/Schema"; +import { beforeEach, describe, expect, it } from "vite-plus/test"; + +import type { DraftId } from "./composerDraftStore"; +import { removeLocalStorageItem, setLocalStorageItem } from "./hooks/useLocalStorage"; +import { + LAST_VISITED_THREAD_ROUTE_STORAGE_KEY, + readLastVisitedThreadRoute, + recordLastVisitedThreadRoute, + resolveRestorableThreadRoute, +} from "./lastVisitedThreadRoute"; + +const LOCAL = EnvironmentId.make("environment-local"); +const REMOTE = EnvironmentId.make("environment-remote"); +const THREAD = ThreadId.make("thread-1"); +const DRAFT = "draft-1" as DraftId; + +describe("lastVisitedThreadRoute", () => { + beforeEach(() => { + removeLocalStorageItem(LAST_VISITED_THREAD_ROUTE_STORAGE_KEY); + }); + + it("remembers the thread each environment had open, separately", () => { + expect(readLastVisitedThreadRoute(LOCAL)).toBeNull(); + + recordLastVisitedThreadRoute(LOCAL, { + kind: "server", + threadRef: { environmentId: LOCAL, threadId: THREAD }, + }); + recordLastVisitedThreadRoute(REMOTE, { kind: "draft", draftId: DRAFT }); + + expect(readLastVisitedThreadRoute(LOCAL)).toEqual({ kind: "server", threadId: THREAD }); + expect(readLastVisitedThreadRoute(REMOTE)).toEqual({ kind: "draft", draftId: DRAFT }); + }); + + it("restores a thread that is still there, on the environment that recorded it", () => { + const entry = { kind: "server", threadId: String(THREAD) } as const; + + expect( + resolveRestorableThreadRoute({ + entry, + environmentId: LOCAL, + serverThreadExists: true, + draftThreadExists: false, + }), + ).toEqual({ kind: "server", threadRef: { environmentId: LOCAL, threadId: THREAD } }); + + expect( + resolveRestorableThreadRoute({ + entry: { kind: "draft", draftId: String(DRAFT) }, + environmentId: LOCAL, + serverThreadExists: false, + draftThreadExists: true, + }), + ).toEqual({ kind: "draft", draftId: DRAFT }); + }); + + it("falls back to the old behaviour when the recorded route is gone", () => { + // A thread deleted from another device, a draft that never came back out + // of storage, or nothing recorded at all: each has to leave the caller on + // its default (start a draft / show the no-thread shell) rather than + // route into a dead page. + expect( + resolveRestorableThreadRoute({ + entry: { kind: "server", threadId: String(THREAD) }, + environmentId: LOCAL, + serverThreadExists: false, + draftThreadExists: true, + }), + ).toBeNull(); + + expect( + resolveRestorableThreadRoute({ + entry: { kind: "draft", draftId: String(DRAFT) }, + environmentId: LOCAL, + serverThreadExists: true, + draftThreadExists: false, + }), + ).toBeNull(); + + expect( + resolveRestorableThreadRoute({ + entry: null, + environmentId: LOCAL, + serverThreadExists: true, + draftThreadExists: true, + }), + ).toBeNull(); + + // No environment yet means nothing to scope the record to. + expect( + resolveRestorableThreadRoute({ + entry: { kind: "server", threadId: String(THREAD) }, + environmentId: null, + serverThreadExists: true, + draftThreadExists: true, + }), + ).toBeNull(); + }); + + it("ignores a record it cannot make sense of instead of throwing at startup", () => { + // A record left by a build with a different shape has to read as "nothing + // remembered": this runs on the load path, so a throw here is a blank app. + setLocalStorageItem( + LAST_VISITED_THREAD_ROUTE_STORAGE_KEY, + { byEnvironmentId: { [String(LOCAL)]: { kind: "somethingElse" } } }, + Schema.Struct({ + byEnvironmentId: Schema.Record(Schema.String, Schema.Struct({ kind: Schema.String })), + }), + ); + + expect(readLastVisitedThreadRoute(LOCAL)).toBeNull(); + }); +}); diff --git a/apps/web/src/lastVisitedThreadRoute.ts b/apps/web/src/lastVisitedThreadRoute.ts new file mode 100644 index 000000000..45329d65e --- /dev/null +++ b/apps/web/src/lastVisitedThreadRoute.ts @@ -0,0 +1,143 @@ +/** + * "Come back where I was" for the chat shell. + * + * Closing and reopening the desktop app used to drop the user on the empty + * root shell: the only thing that ever navigated away from `/` was the `npx` + * launch bootstrap (`bootstrapThreadId` on the welcome payload), and a warm + * relaunch has no bootstrap to replay. Nothing recorded where the user + * actually was, so nothing could restore it. + * + * This module records the chat route the user is on and hands it back on the + * next load. It is deliberately small: one entry per environment, validated on + * read, and always checked against live state before it is used, so a thread + * deleted from another device falls back to the old behaviour instead of + * routing into a dead page. + * + * @module lastVisitedThreadRoute + */ +import type { EnvironmentId, ThreadId } from "@threadlines/contracts"; +import { scopeThreadRef } from "@threadlines/client-runtime"; +import * as Schema from "effect/Schema"; + +import type { DraftId } from "./composerDraftStore"; +import { getLocalStorageItemWithLegacyKeys, setLocalStorageItem } from "./hooks/useLocalStorage"; +import type { ThreadRouteTarget } from "./threadRoutes"; + +export const LAST_VISITED_THREAD_ROUTE_STORAGE_KEY = "threadlines:last-visited-thread-route:v1"; +/** Minted after the t3code rename, so nothing predates it. */ +const LEGACY_LAST_VISITED_THREAD_ROUTE_STORAGE_KEYS: readonly string[] = []; + +const LastVisitedThreadRouteEntrySchema = Schema.Union([ + Schema.Struct({ kind: Schema.Literal("server"), threadId: Schema.String }), + Schema.Struct({ kind: Schema.Literal("draft"), draftId: Schema.String }), +]); + +/** What was open, minus the environment (which is the record's key). */ +export type LastVisitedThreadRouteEntry = typeof LastVisitedThreadRouteEntrySchema.Type; + +const LastVisitedThreadRoutesSchema = Schema.Struct({ + byEnvironmentId: Schema.Record(Schema.String, LastVisitedThreadRouteEntrySchema), +}); + +type LastVisitedThreadRoutes = typeof LastVisitedThreadRoutesSchema.Type; + +const EMPTY_RECORD: LastVisitedThreadRoutes = { byEnvironmentId: {} }; + +function readRecord(): LastVisitedThreadRoutes { + try { + return ( + getLocalStorageItemWithLegacyKeys( + LAST_VISITED_THREAD_ROUTE_STORAGE_KEY, + LEGACY_LAST_VISITED_THREAD_ROUTE_STORAGE_KEYS, + LastVisitedThreadRoutesSchema, + ) ?? EMPTY_RECORD + ); + } catch { + return EMPTY_RECORD; + } +} + +/** What this environment had open last, or null if it was never recorded. */ +export function readLastVisitedThreadRoute( + environmentId: EnvironmentId | null | undefined, +): LastVisitedThreadRouteEntry | null { + if (!environmentId) { + return null; + } + return readRecord().byEnvironmentId[String(environmentId)] ?? null; +} + +function sameEntry( + left: LastVisitedThreadRouteEntry | undefined, + right: LastVisitedThreadRouteEntry, +): boolean { + if (!left || left.kind !== right.kind) { + return false; + } + return left.kind === "server" && right.kind === "server" + ? left.threadId === right.threadId + : left.kind === "draft" && right.kind === "draft" + ? left.draftId === right.draftId + : false; +} + +export function recordLastVisitedThreadRoute( + environmentId: EnvironmentId | null | undefined, + target: ThreadRouteTarget | null, +): void { + if (!environmentId || target === null) { + return; + } + const entry: LastVisitedThreadRouteEntry = + target.kind === "server" + ? { kind: "server", threadId: String(target.threadRef.threadId) } + : { kind: "draft", draftId: String(target.draftId) }; + const current = readRecord(); + if (sameEntry(current.byEnvironmentId[String(environmentId)], entry)) { + return; + } + + try { + setLocalStorageItem( + LAST_VISITED_THREAD_ROUTE_STORAGE_KEY, + { + byEnvironmentId: { ...current.byEnvironmentId, [String(environmentId)]: entry }, + }, + LastVisitedThreadRoutesSchema, + ); + } catch { + // Best-effort convenience state; a storage failure must not break routing. + } +} + +/** + * The route to restore on load, or null to keep the previous behaviour + * (start a draft in the default project, or show the no-thread shell). + * + * Existence is decided by the caller against live state rather than in here: + * a thread archived or deleted since the last session must not be restored, + * and the same goes for a draft that never made it back out of storage. + */ +export function resolveRestorableThreadRoute(input: { + readonly entry: LastVisitedThreadRouteEntry | null; + readonly environmentId: EnvironmentId | null | undefined; + readonly serverThreadExists: boolean; + readonly draftThreadExists: boolean; +}): ThreadRouteTarget | null { + if (!input.entry || !input.environmentId) { + return null; + } + + if (input.entry.kind === "server") { + return input.serverThreadExists + ? { + kind: "server", + threadRef: scopeThreadRef(input.environmentId, input.entry.threadId as ThreadId), + } + : null; + } + + return input.draftThreadExists + ? { kind: "draft", draftId: input.entry.draftId as DraftId } + : null; +} diff --git a/apps/web/src/providerInstances.ts b/apps/web/src/providerInstances.ts index c62c53a54..20eb3f77f 100644 --- a/apps/web/src/providerInstances.ts +++ b/apps/web/src/providerInstances.ts @@ -203,6 +203,20 @@ export function filterMaintainedProviderInstanceEntries( return entries.filter((entry) => isMaintainedProviderDriverKind(entry.driverKind)); } +/** + * The list every user-facing surface shows: maintained drivers only, in + * default-first picker order. Composer, chat view, and the first-run setup + * card all need the same projection, and disagreeing on it would mean one + * surface offering an instance another refuses to run. + */ +export function deriveDisplayProviderInstanceEntries( + providers: ReadonlyArray, +): ReadonlyArray { + return filterMaintainedProviderInstanceEntries( + sortProviderInstanceEntries(deriveProviderInstanceEntries(providers)), + ); +} + /** * Look up a single instance entry by exact `instanceId`. Missing snapshots * are not inferred from driver kind in UI routing code. diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 0b8566a4b..2d24068d2 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -42,7 +42,6 @@ import { resolveThreadRouteRef, buildThreadRouteParams } from "../threadRoutes"; import { RightPanelSheet } from "../components/RightPanelSheet"; import { SidebarInset } from "~/components/ui/sidebar"; import { cn } from "~/lib/utils"; -import { useUiStateStore } from "../uiStateStore"; import { SourceControlPanel, type SourceControlProjectTarget, @@ -100,7 +99,6 @@ function ChatThreadRouteView() { const savedEnvironmentLabel = useSavedEnvironmentRegistryStore((state) => threadRef ? (state.byId[threadRef.environmentId]?.label ?? null) : null, ); - const setLastChatThreadRef = useUiStateStore((state) => state.setLastChatThreadRef); const search = Route.useSearch(); const shouldUseDiffSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); const bootstrapComplete = useStore( @@ -173,10 +171,6 @@ function ChatThreadRouteView() { const queryClient = useQueryClient(); const currentThreadKey = threadRef ? `${threadRef.environmentId}:${threadRef.threadId}` : null; - useEffect(() => { - setLastChatThreadRef(threadRef); - }, [setLastChatThreadRef, threadRef]); - // Register the workspace context so chat file chips, diff rows, and the // terminal affordance can open the internal file viewer from anywhere in // this route without prop drilling. General Chat threads browse their diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index b3252fa35..db3209be6 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -1,5 +1,6 @@ -import { scopedProjectKey } from "@threadlines/client-runtime"; -import { createFileRoute } from "@tanstack/react-router"; +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 { useShallow } from "zustand/react/shallow"; @@ -10,15 +11,22 @@ import { WorkspaceLoadingState, } from "../components/ConnectionStatusStates"; import { NoActiveThreadState } from "../components/NoActiveThreadState"; +import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { useSavedEnvironmentRegistryStore, useSavedEnvironmentRuntimeStore, } from "../environments/runtime"; +import { + readLastVisitedThreadRoute, + resolveRestorableThreadRoute, +} from "../lastVisitedThreadRoute"; import { selectBootstrapCompleteForActiveEnvironment, selectProjectsAcrossEnvironments, + selectThreadExistsByRef, useStore, } from "../store"; +import { buildDraftThreadRouteParams, buildThreadRouteParams } from "../threadRoutes"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { deriveChatIndexState } from "./-chatIndexState"; @@ -57,20 +65,76 @@ 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. + */ function DefaultProjectDraftRedirect() { + const navigate = useNavigate(); const { defaultProjectRef, handleNewThread } = useHandleNewThread(); + const activeEnvironmentId = useStore((state) => state.activeEnvironmentId); const defaultProjectKey = defaultProjectRef ? scopedProjectKey(defaultProjectRef) : null; - const openDefaultProjectDraft = useEffectEvent(() => { + + 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; + } + const restored = resolveRestorableThreadRoute({ + entry, + environmentId: activeEnvironmentId, + serverThreadExists: + entry?.kind === "server" && activeEnvironmentId + ? selectThreadExistsByRef( + useStore.getState(), + scopeThreadRef(activeEnvironmentId, entry.threadId as ThreadId), + ) + : false, + draftThreadExists: + entry?.kind === "draft" + ? useComposerDraftStore.getState().getDraftSession(entry.draftId as DraftId) !== null + : false, + }); + + if (restored?.kind === "server") { + void navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(restored.threadRef), + replace: true, + }); + return; + } + if (restored?.kind === "draft") { + void navigate({ + to: "/draft/$draftId", + params: buildDraftThreadRouteParams(restored.draftId), + replace: true, + }); + return; + } if (defaultProjectRef) { void handleNewThread(defaultProjectRef, { replace: true }); } }); useEffect(() => { - if (defaultProjectKey) { - openDefaultProjectDraft(); - } - }, [defaultProjectKey]); + openLastVisitedOrDefaultDraft(); + }, [activeEnvironmentId, defaultProjectKey]); return defaultProjectRef === null ? : null; } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index da22d7e60..af52defb4 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,8 +1,13 @@ -import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; +import { scopeThreadRef } from "@threadlines/client-runtime"; +import { Outlet, createFileRoute, redirect, useParams } from "@tanstack/react-router"; import { useEffect } from "react"; import { useCommandPaletteStore } from "../commandPaletteStore"; +import { useComposerDraftStore } from "../composerDraftStore"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { recordLastVisitedThreadRoute } from "../lastVisitedThreadRoute"; +import { resolveThreadRouteTarget } from "../threadRoutes"; +import { useStore } from "../store"; import { startNewLocalThreadFromContext, startNewThreadFromContext, @@ -97,10 +102,59 @@ function ChatRouteGlobalShortcuts() { return null; } +/** + * Remembers the thread or draft on screen so the next launch can reopen it. + * + * Sits on the chat layout rather than in each route because both chat routes + * count and the record has to look the same either way; a draft carries its + * environment on the draft session instead of in the URL. + */ +function LastVisitedThreadRouteRecorder() { + const routeTarget = useParams({ + strict: false, + select: (params) => resolveThreadRouteTarget(params), + }); + // Kept as primitives: this component sits above the composer, so anything + // that changes identity per render would rewrite storage on every keystroke. + const routeThreadEnvironmentId = + routeTarget?.kind === "server" ? routeTarget.threadRef.environmentId : null; + const routeThreadId = routeTarget?.kind === "server" ? routeTarget.threadRef.threadId : null; + const routeDraftId = routeTarget?.kind === "draft" ? routeTarget.draftId : null; + const draftEnvironmentId = useComposerDraftStore((store) => + routeDraftId ? (store.getDraftSession(routeDraftId)?.environmentId ?? null) : null, + ); + const activeEnvironmentId = useStore((state) => state.activeEnvironmentId); + + useEffect(() => { + if (routeThreadEnvironmentId && routeThreadId) { + recordLastVisitedThreadRoute(routeThreadEnvironmentId, { + kind: "server", + threadRef: scopeThreadRef(routeThreadEnvironmentId, routeThreadId), + }); + return; + } + if (routeDraftId) { + recordLastVisitedThreadRoute(draftEnvironmentId ?? activeEnvironmentId, { + kind: "draft", + draftId: routeDraftId, + }); + } + }, [ + activeEnvironmentId, + draftEnvironmentId, + routeDraftId, + routeThreadEnvironmentId, + routeThreadId, + ]); + + return null; +} + function ChatRouteLayout() { return ( <> + ); diff --git a/apps/web/src/uiStateStore.test.ts b/apps/web/src/uiStateStore.test.ts index 1f7f8d816..bebe58c6a 100644 --- a/apps/web/src/uiStateStore.test.ts +++ b/apps/web/src/uiStateStore.test.ts @@ -1,4 +1,4 @@ -import { EnvironmentId, ProjectId, ThreadId } from "@threadlines/contracts"; +import { ProjectId, ThreadId } from "@threadlines/contracts"; import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; import { @@ -12,7 +12,6 @@ import { reorderProjects, resolveSeenOverlay, setDefaultAdvertisedEndpointKey, - setLastChatThreadRef, setProjectExpanded, setThreadChangedFilesExpanded, syncProjects, @@ -30,7 +29,6 @@ function makeUiState(overrides: Partial = {}): UiState { doneThreadOverlays: {}, inboxProjectScopeKey: null, defaultAdvertisedEndpointKey: null, - lastChatThreadRef: null, ...overrides, }; } @@ -104,20 +102,6 @@ describe("uiStateStore pure functions", () => { }); }); - it("setLastChatThreadRef remembers the active chat thread without churn", () => { - const initialState = makeUiState(); - const threadRef = { - environmentId: EnvironmentId.make("env-local"), - threadId: ThreadId.make("thread-1"), - }; - - const next = setLastChatThreadRef(initialState, threadRef); - - expect(next.lastChatThreadRef).toEqual(threadRef); - expect(setLastChatThreadRef(next, threadRef)).toBe(next); - expect(setLastChatThreadRef(next, null).lastChatThreadRef).toBeNull(); - }); - it("reorderProjects moves all member keys of a multi-member group together", () => { const keyALocal = "env-local:proj-a"; const keyARemote = "env-remote:proj-a"; diff --git a/apps/web/src/uiStateStore.ts b/apps/web/src/uiStateStore.ts index 322befa74..2f63d6904 100644 --- a/apps/web/src/uiStateStore.ts +++ b/apps/web/src/uiStateStore.ts @@ -1,5 +1,4 @@ import { Debouncer } from "@tanstack/react-pacer"; -import type { ScopedThreadRef } from "@threadlines/contracts"; import { create } from "zustand"; export const PERSISTED_STATE_KEY = "threadlines:ui-state:v1"; @@ -86,12 +85,7 @@ export interface UiEndpointState { defaultAdvertisedEndpointKey: string | null; } -export interface UiNavigationState { - lastChatThreadRef: ScopedThreadRef | null; -} - -export interface UiState - extends UiProjectState, UiThreadState, UiInboxState, UiEndpointState, UiNavigationState {} +export interface UiState extends UiProjectState, UiThreadState, UiInboxState, UiEndpointState {} export interface SyncProjectInput { /** Physical project key (env + cwd). Used for manual sort order. */ @@ -118,7 +112,6 @@ const initialState: UiState = { doneThreadOverlays: {}, inboxProjectScopeKey: null, defaultAdvertisedEndpointKey: null, - lastChatThreadRef: null, }; const persistedCollapsedProjectCwds = new Set(); @@ -801,20 +794,6 @@ export function setDefaultAdvertisedEndpointKey(state: UiState, key: string | nu }; } -export function setLastChatThreadRef(state: UiState, threadRef: ScopedThreadRef | null): UiState { - const nextThreadRef = threadRef ? { ...threadRef } : null; - if ( - state.lastChatThreadRef?.environmentId === nextThreadRef?.environmentId && - state.lastChatThreadRef?.threadId === nextThreadRef?.threadId - ) { - return state; - } - return { - ...state, - lastChatThreadRef: nextThreadRef, - }; -} - export function toggleProject(state: UiState, projectId: string): UiState { const expanded = state.projectExpandedById[projectId] ?? true; return { @@ -898,7 +877,6 @@ interface UiStateStore extends UiState { defaultExpanded: boolean, ) => void; setDefaultAdvertisedEndpointKey: (key: string | null) => void; - setLastChatThreadRef: (threadRef: ScopedThreadRef | null) => void; toggleProject: (projectId: string) => void; setProjectExpanded: (projectId: string, expanded: boolean) => void; reorderProjects: ( @@ -930,7 +908,6 @@ export const useUiStateStore = create((set) => ({ ), setDefaultAdvertisedEndpointKey: (key) => set((state) => setDefaultAdvertisedEndpointKey(state, key)), - setLastChatThreadRef: (threadRef) => set((state) => setLastChatThreadRef(state, threadRef)), toggleProject: (projectId) => set((state) => toggleProject(state, projectId)), setProjectExpanded: (projectId, expanded) => set((state) => setProjectExpanded(state, projectId, expanded)), From e9ec1ffbdcaedfddfcc2c926ebffcfcce6ae9771 Mon Sep 17 00:00:00 2001 From: badcuban <108198679+badcuban@users.noreply.github.com> Date: Sat, 8 Aug 2026 02:16:07 -0400 Subject: [PATCH 2/2] Make palette items findable by their visible title Ranking only consulted searchTerms, so typing exactly what an item says on screen ("Go to Home") found nothing unless someone remembered to duplicate the title into the terms - some items did, most didn't. The title now ranks first when it is a plain string; hidden searchTerms follow. Found by the desktop first-run verification walk (V-7). --- .../components/CommandPalette.logic.test.ts | 28 +++++++++++++++++++ .../src/components/CommandPalette.logic.ts | 7 ++++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index a09b7ec30..ea896a2ab 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -123,6 +123,34 @@ describe("buildThreadActionItems", () => { ]); }); + it("finds an item by its visible title even when the title is not a search term", () => { + const group: CommandPaletteGroup = { + value: "actions", + label: "Actions", + items: [ + { + kind: "action", + value: "action:go-home", + searchTerms: ["home", "landing", "start", "threads"], + title: "Go to Home", + icon: null, + run: async () => undefined, + }, + ], + }; + + const groups = filterCommandPaletteGroups({ + activeGroups: [group], + query: "Go to Home", + isInSubmenu: false, + projectSearchItems: [], + threadSearchItems: [], + }); + + expect(groups).toHaveLength(1); + expect(groups[0]?.items.map((item) => item.value)).toEqual(["action:go-home"]); + }); + it("preserves thread project-name matches when there is no stronger title match", () => { const group: CommandPaletteGroup = { value: "threads-search", diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index d741dbeb6..d97929030 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -255,7 +255,12 @@ function rankCommandPaletteItemMatch( item: CommandPaletteActionItem | CommandPaletteSubmenuItem, query: ParsedSearchQuery, ): number { - const terms = item.searchTerms.filter((term) => term.length > 0); + // The visible title is always searchable, and outranks the hidden + // searchTerms: typing exactly what an item says on screen must find it. + // Titles can be arbitrary nodes; only plain-string ones can be ranked. + const terms = [typeof item.title === "string" ? item.title : "", ...item.searchTerms].filter( + (term) => term.length > 0, + ); let bestRank = Number.NEGATIVE_INFINITY; for (const [index, field] of terms.entries()) {