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/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()) { 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)),