diff --git a/apps/web/src/branding.ts b/apps/web/src/branding.ts index 09989e8d5..cf5a4de91 100644 --- a/apps/web/src/branding.ts +++ b/apps/web/src/branding.ts @@ -15,6 +15,8 @@ export const HOSTED_APP_CHANNEL = hostedAppChannel === "latest" || hostedAppChannel === "nightly" ? hostedAppChannel : null; export const HOSTED_APP_CHANNEL_LABEL = HOSTED_APP_CHANNEL === "nightly" ? "Nightly" : HOSTED_APP_CHANNEL === "latest" ? "Latest" : null; +/** Marketing-site download page, for surfaces that have no desktop app yet. */ +export const DESKTOP_DOWNLOAD_URL = "https://threadlines.dev/download"; export const APP_BASE_NAME = injectedDesktopAppBranding?.baseName ?? "Threadlines"; export const APP_STAGE_LABEL = injectedDesktopAppBranding?.stageLabel ?? diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 818202c8a..cd0de4f87 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -3740,6 +3740,63 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("holds back a send to a signed-out provider until the user chooses Send anyway", async () => { + setDraftThreadWithoutWorktree(); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createDraftOnlySnapshot(), + configureFixture: (nextFixture) => { + nextFixture.serverConfig = { + ...nextFixture.serverConfig, + providers: [ + { + ...nextFixture.serverConfig.providers[0]!, + status: "warning", + auth: { status: "unauthenticated" }, + }, + ], + }; + }, + resolveRpc: (body) => + body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand + ? { sequence: fixture.snapshot.snapshotSequence + 1 } + : undefined, + }); + + const turnStartRequests = () => + wsRequests.filter( + (request) => + request._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + request.type === "thread.turn.start", + ); + + try { + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Explain this repo"); + await waitForLayout(); + + (await waitForSendButton()).click(); + + const sendAnyway = await waitForButtonByText("Send anyway"); + // The turn never left the client, and the draft survived the interruption. + expect(turnStartRequests()).toHaveLength(0); + expect(document.body.textContent).toContain("Codex sign-in required"); + expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt).toBe( + "Explain this repo", + ); + + sendAnyway.click(); + + await vi.waitFor( + () => { + expect(turnStartRequests()).toHaveLength(1); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps custom provider instance ids when bootstrapping a local draft thread", async () => { setDraftThreadWithoutWorktree(); const openRouterInstanceId = ProviderInstanceId.make("claude_openrouter"); diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 9d425b9c4..f8a311701 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -7,6 +7,7 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + type ServerProvider, ThreadId, TurnId, } from "@threadlines/contracts"; @@ -28,6 +29,7 @@ import { deriveDetectedBackgroundRunLabel, deriveFailedTurnRetryMessageId, deriveProviderBackgroundRuns, + deriveProviderSendPreflight, deriveProviderAuthReconnectPrompt, desktopCapturedScreenshotToFile, filterUnresolvedProviderBackgroundRuns, @@ -2608,3 +2610,76 @@ describe("resolveRemoteBehindCount", () => { expect(resolveRemoteBehindCount(null)).toBeNull(); }); }); + +describe("deriveProviderSendPreflight", () => { + const makeProvider = (overrides: Partial = {}): ServerProvider => + ({ + auth: { status: "authenticated" }, + checkedAt: "2026-08-01T00:00:00.000Z", + displayName: "Codex", + driver: ProviderDriverKind.make("codex"), + enabled: true, + installed: true, + instanceId: ProviderInstanceId.make("codex"), + models: [], + skills: [], + slashCommands: [], + status: "ready", + version: "1.0.0", + ...overrides, + }) satisfies ServerProvider; + + it("interrupts a send to a signed-out instance and names the login command", () => { + expect( + deriveProviderSendPreflight({ + instanceId: ProviderInstanceId.make("codex"), + providers: [makeProvider({ auth: { status: "unauthenticated" }, status: "warning" })], + }), + ).toEqual({ + reason: "notAuthenticated", + provider: ProviderDriverKind.make("codex"), + instanceId: ProviderInstanceId.make("codex"), + providerLabel: "Codex", + command: "codex login", + }); + }); + + it("interrupts a send to an instance whose CLI is missing", () => { + expect( + deriveProviderSendPreflight({ + instanceId: ProviderInstanceId.make("codex"), + providers: [ + makeProvider({ installed: false, status: "warning", auth: { status: "unknown" } }), + ], + })?.reason, + ).toBe("notInstalled"); + }); + + it("lets a usable instance through", () => { + expect( + deriveProviderSendPreflight({ + instanceId: ProviderInstanceId.make("codex"), + providers: [makeProvider()], + }), + ).toBeNull(); + }); + + it("stays out of the way when the provider snapshot cannot judge auth", () => { + expect( + deriveProviderSendPreflight({ + instanceId: ProviderInstanceId.make("codex"), + providers: [makeProvider({ auth: { status: "unknown" } })], + }), + ).toBeNull(); + }); + + it("never interrupts on a selection it has no snapshot for", () => { + expect( + deriveProviderSendPreflight({ + instanceId: ProviderInstanceId.make("codex-personal"), + providers: [makeProvider({ auth: { status: "unauthenticated" } })], + }), + ).toBeNull(); + expect(deriveProviderSendPreflight({ instanceId: null, providers: [] })).toBeNull(); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index b50965f79..af4a28edd 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -6,7 +6,9 @@ import { type ModelSelection, type OrchestrationThreadActivity, type ProviderDriverKind, + type ProviderInstanceId, type ScopedThreadRef, + type ServerProvider, type ThreadId, type TurnId, } from "@threadlines/contracts"; @@ -17,7 +19,9 @@ import { } from "@threadlines/shared/providerAuth"; import { normalizeTerminalActivityCommand } from "@threadlines/shared/terminalCommandTracker"; import type { DesktopCapturedScreenshot } from "@threadlines/contracts"; +import { getModelPickerProviderAvailability } from "./chat/modelPickerEmptyState"; import { isProviderUsageLimitErrorMessage } from "./ProviderRateLimitResetCredit"; +import { formatProviderDriverKindLabel } from "../providerModels"; import { type ChatMessage, type SessionPhase, type Thread, type ThreadSession } from "../types"; import { type ComposerAttachment, type DraftThreadState } from "../composerDraftStore"; import * as Schema from "effect/Schema"; @@ -1463,6 +1467,53 @@ export interface ProviderAuthReconnectPrompt { readonly message: string; } +export interface ProviderSendPreflightPrompt { + readonly reason: "notInstalled" | "notAuthenticated"; + readonly provider: ProviderDriverKind; + readonly instanceId: ProviderInstanceId; + readonly providerLabel: string; + /** Terminal login command, when this provider has one. */ + readonly command: string | null; +} + +/** + * Decides whether a send should be interrupted before it is dispatched. + * + * A signed-out CLI still accepts the turn and then burns half a minute of + * reconnect attempts before surfacing a raw `401`, so when the snapshot we + * already hold says the selected instance cannot serve the turn, we say so up + * front. Provider snapshots go stale, so this only ever guides: the caller + * keeps a "Send anyway" path, and an `available` verdict (which includes an + * auth state we cannot judge) never interrupts anything. + */ +export function deriveProviderSendPreflight(input: { + readonly instanceId: ProviderInstanceId | null | undefined; + readonly providers: ReadonlyArray; +}): ProviderSendPreflightPrompt | null { + const instanceId = input.instanceId; + if (!instanceId) { + return null; + } + + const provider = input.providers.find((candidate) => candidate.instanceId === instanceId); + if (!provider) { + return null; + } + + const availability = getModelPickerProviderAvailability(provider); + if (availability === "available") { + return null; + } + + return { + reason: availability, + provider: provider.driver, + instanceId: provider.instanceId, + providerLabel: provider.displayName?.trim() || formatProviderDriverKindLabel(provider.driver), + command: providerAuthReconnectCommand(provider.driver) ?? null, + }; +} + export function shouldRenderThreadErrorBanner(input: { readonly threadError: string | null | undefined; readonly hasInlineProviderAuthError: boolean; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 85191f0c4..7af1543e0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -212,6 +212,7 @@ import { shouldRenderProviderStatusBanner, } from "./chat/ProviderStatusBanner"; import { SessionStartupNotice } from "./chat/SessionStartupNotice"; +import { ProviderSendPreflightNotice } from "./chat/ProviderReadinessNotice"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { ComposerBannerStack, type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { @@ -226,6 +227,8 @@ import { deriveFailedTurnRetryMessageId, deriveComposerSendState, deriveProviderAuthReconnectPrompt, + deriveProviderSendPreflight, + type ProviderSendPreflightPrompt, filterUnresolvedProviderBackgroundRuns, hasServerAcknowledgedLocalDispatch, isRetryableThreadError, @@ -2483,6 +2486,16 @@ export default function ChatView(props: ChatViewProps) { timelineMessages, ], ); + // Set when a send was held back because the selected provider instance is + // already known to be unusable. Cleared by dismissing the notice, by "Send + // anyway", and by any later send that preflights clean. + const [providerSendPreflight, setProviderSendPreflight] = + useState(null); + useEffect(() => { + // The notice belongs to the send it interrupted, so it must not follow the + // user into another thread. + setProviderSendPreflight(null); + }, [activeThreadKey]); const providerStatusBannerVisible = shouldRenderProviderStatusBanner(activeProviderStatus, { activeTurnInProgress, }); @@ -4270,7 +4283,10 @@ export default function ChatView(props: ChatViewProps) { setThreadError, ]); - const onSend = async (e?: { preventDefault: () => void }) => { + const onSend = async ( + e?: { preventDefault: () => void }, + options?: { readonly skipProviderPreflight?: boolean }, + ) => { e?.preventDefault(); const api = readEnvironmentApi(environmentId); const activeSteerTurnId = @@ -4392,6 +4408,20 @@ export default function ChatView(props: ChatViewProps) { } return; } + // Hold the turn back when the snapshot already says this instance cannot + // serve it. The draft is untouched, so dismissing or fixing the provider + // returns the user to exactly what they typed. + if (options?.skipProviderPreflight !== true) { + const preflight = deriveProviderSendPreflight({ + instanceId: ctxSelectedModelSelection.instanceId, + providers: providerStatuses, + }); + if (preflight) { + setProviderSendPreflight(preflight); + return; + } + } + setProviderSendPreflight(null); if (!activeProject) return; const threadIdForSend = activeThread.id; const isFirstMessage = !isServerThread || activeThread.messages.length === 0; @@ -6195,6 +6225,21 @@ export default function ChatView(props: ChatViewProps) { onRunAuthReconnect={runProviderAuthReconnect} onDismiss={() => setThreadError(activeThread.id, null)} /> + { + void runProviderAuthReconnect({ + provider: prompt.provider, + command: prompt.command ?? "", + message: `${prompt.providerLabel} is not signed in.`, + }); + }} + onSendAnyway={() => { + setProviderSendPreflight(null); + void onSend(undefined, { skipProviderPreflight: true }); + }} + onDismiss={() => setProviderSendPreflight(null)} + /> {threadErrorRateLimitResetCreditDialog} { ]); }); }); + +describe("resolveAddProjectUnavailableGuidance", () => { + it("routes an unpaired hosted visitor to Devices instead of reporting a failure", () => { + const guidance = resolveAddProjectUnavailableGuidance({ isHostedStatic: true }); + + expect(guidance.type).toBe("warning"); + expect(guidance.title).toBe("Pair a computer to add projects"); + expect(guidance.action).toEqual({ label: "Open Devices", to: "/settings/connections" }); + }); + + it("keeps the plain error for a desktop session that really has no environment", () => { + expect(resolveAddProjectUnavailableGuidance({ isHostedStatic: false })).toEqual({ + type: "error", + title: "Unable to browse projects", + description: "No environment is available.", + action: null, + }); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index 88ebe4a87..d741dbeb6 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -12,6 +12,10 @@ import { } from "@threadlines/shared/searchRanking"; import { type ReactNode } from "react"; import { sortThreads } from "../lib/threadSort"; +import { + DEVICES_SETTINGS_SECTION_PATH, + type SettingsSectionPath, +} from "./settings/settingsNavigation"; import { formatRelativeTimeLabel } from "../timestampFormat"; import { type Project, type SidebarThreadSummary, type Thread } from "../types"; @@ -430,3 +434,36 @@ export function getCommandPaletteInputPlaceholder(mode: CommandPaletteMode): str return "Enter path (e.g. ~/projects/my-app)"; } } + +/** + * Copy for the add-project action when there is no environment to browse. + * + * In the hosted browser that state is not a failure, it is the setup step the + * visitor has not done yet: projects live on the paired computer, so there is + * nothing to browse until one exists. Say that and point at pairing instead of + * reporting a bare "no environment" error. A desktop session that somehow has + * no environment is a real fault, so it keeps the error wording. + */ +export function resolveAddProjectUnavailableGuidance(input: { readonly isHostedStatic: boolean }): { + readonly type: "error" | "warning"; + readonly title: string; + readonly description: string; + readonly action: { readonly label: string; readonly to: SettingsSectionPath } | null; +} { + if (!input.isHostedStatic) { + return { + type: "error", + title: "Unable to browse projects", + description: "No environment is available.", + action: null, + }; + } + + return { + type: "warning", + title: "Pair a computer to add projects", + description: + "Threadlines browses projects on the computer running the desktop app. Pair one from Devices, then add a project.", + action: { label: "Open Devices", to: DEVICES_SETTINGS_SECTION_PATH }, + }; +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 5a16bff20..d60fbacfd 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -115,7 +115,9 @@ import { ITEM_ICON_CLASS, normalizeSearchText, RECENT_THREAD_LIMIT, + resolveAddProjectUnavailableGuidance, } from "./CommandPalette.logic"; +import { isHostedStaticApp } from "../hostedPairing"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; @@ -1272,11 +1274,26 @@ function OpenCommandPaletteDialog() { const environmentId = defaultAddProjectEnvironmentId; if (!environmentId) { + const guidance = resolveAddProjectUnavailableGuidance({ + isHostedStatic: isHostedStaticApp(), + }); + const guidanceAction = guidance.action; toastManager.add( stackedThreadToast({ - type: "error", - title: "Unable to browse projects", - description: "No environment is available.", + type: guidance.type, + title: guidance.title, + description: guidance.description, + ...(guidanceAction + ? { + actionProps: { + children: guidanceAction.label, + onClick: () => { + void navigate({ to: guidanceAction.to }); + }, + }, + actionVariant: "outline" as const, + } + : {}), }), ); return; @@ -1287,6 +1304,7 @@ function OpenCommandPaletteDialog() { addProjectEnvironmentGroups, addProjectEnvironmentOptions.length, defaultAddProjectEnvironmentId, + navigate, startAddProjectSourceSelection, ]); diff --git a/apps/web/src/components/HostedStaticStatusStates.browser.tsx b/apps/web/src/components/HostedStaticStatusStates.browser.tsx new file mode 100644 index 000000000..7457f2b55 --- /dev/null +++ b/apps/web/src/components/HostedStaticStatusStates.browser.tsx @@ -0,0 +1,46 @@ +import "../index.css"; + +import { + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, +} from "@tanstack/react-router"; +import type { ReactNode } from "react"; +import { page } from "vite-plus/test/browser"; +import { describe, expect, it } from "vite-plus/test"; +import { render } from "vitest-browser-react"; + +import { HostedStaticOnboardingState } from "./HostedStaticStatusStates"; +import { SidebarProvider } from "./ui/sidebar"; + +function renderWithTestRouter(children: ReactNode) { + const rootRoute = createRootRoute({ + component: () => {children}, + }); + const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: "/" }); + const connectionsRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/settings/connections", + }); + const router = createRouter({ + routeTree: rootRoute.addChildren([indexRoute, connectionsRoute]), + history: createMemoryHistory({ initialEntries: ["/"] }), + }); + + return render(); +} + +describe("HostedStaticOnboardingState", () => { + it("offers a download and a route to pairing instead of dead-ending", async () => { + renderWithTestRouter(); + + await expect + .element(page.getByRole("link", { name: "Download the desktop app" })) + .toHaveAttribute("href", "https://threadlines.dev/download"); + await expect + .element(page.getByRole("link", { name: "Pair this browser" })) + .toHaveAttribute("href", "/settings/connections"); + }); +}); diff --git a/apps/web/src/components/HostedStaticStatusStates.tsx b/apps/web/src/components/HostedStaticStatusStates.tsx index fb1504d61..9c6a35efd 100644 --- a/apps/web/src/components/HostedStaticStatusStates.tsx +++ b/apps/web/src/components/HostedStaticStatusStates.tsx @@ -1,9 +1,18 @@ -import { LoaderCircleIcon, MonitorIcon, RefreshCwIcon, WifiOffIcon } from "lucide-react"; +import { Link } from "@tanstack/react-router"; +import { + DownloadIcon, + LoaderCircleIcon, + MonitorIcon, + RefreshCwIcon, + SmartphoneIcon, + WifiOffIcon, +} from "lucide-react"; import { type ReactNode } from "react"; -import { APP_DISPLAY_NAME } from "../branding"; +import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "../branding"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; import { cn } from "../lib/utils"; +import { DEVICES_SETTINGS_SECTION_PATH } from "./settings/settingsNavigation"; import { Button } from "./ui/button"; import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty"; import { SidebarInset, SidebarOpenTrigger } from "./ui/sidebar"; @@ -23,7 +32,7 @@ function HostedStaticStatusState({ icon: ReactNode; title: string; description: string; - detail: string; + detail?: string; action?: ReactNode; }) { return ( @@ -53,9 +62,11 @@ function HostedStaticStatusState({ {description} -

- {detail} -

+ {detail ? ( +

+ {detail} +

+ ) : null} {action ?
{action}
: null} @@ -110,41 +121,37 @@ export function HostedStaticConnectionErrorState({ ); } +/** + * What a visitor who has never paired sees. The desktop app is the only place + * that mints a setup link (Settings, then Devices, then Add device), so the + * copy names that path rather than implying this browser can start pairing on + * its own, and the two actions cover both halves of the setup: get the app, + * then bring the link back here. + */ export function HostedStaticOnboardingState() { return ( - -
-
-
- - - {APP_DISPLAY_NAME} - -
-
- - -
- -
- -
- - Open the desktop app to get started - - - Threadlines is focused on the local desktop workflow. Start or resume a session from - the desktop app. - -
-
-
-
-
+ } + title="Open the desktop app to get started" + description="Threadlines runs on your computer. Install the desktop app there, then pair this browser so it can reach your projects and threads." + detail="The desktop app creates the setup link: open Settings, then Devices, then Add device. Paste that link here to finish pairing." + action={ +
+ +
+ } + /> ); } diff --git a/apps/web/src/components/chat/ProviderReadinessNotice.tsx b/apps/web/src/components/chat/ProviderReadinessNotice.tsx new file mode 100644 index 000000000..e8438db0c --- /dev/null +++ b/apps/web/src/components/chat/ProviderReadinessNotice.tsx @@ -0,0 +1,165 @@ +/** + * The two cards that explain an unusable provider above the composer. + * + * They are shown from two directions: after a turn already failed on a + * provider auth error (`ThreadErrorBanner`) and before a send we can tell in + * advance will fail (ChatView's send preflight). Both directions have to read + * the same, so the markup and copy live here once instead of being restated + * per surface. + * + * @module ProviderReadinessNotice + */ +import { Link } from "@tanstack/react-router"; +import { SendHorizontalIcon, SettingsIcon, TerminalIcon, XIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { ProviderSendPreflightPrompt } from "../ChatView.logic"; +import { Button } from "../ui/button"; +import { CompactStatusNoticeRow } from "./statusNotice"; + +export function DismissNoticeButton({ + label = "Dismiss error", + onDismiss, +}: { + label?: string; + onDismiss: () => void; +}) { + return ( + + ); +} + +export function ProviderSignInNotice({ + providerLabel, + command, + retryHint = "retry", + errorText, + extraActions, + onRunSignIn, + onDismiss, +}: { + providerLabel: string; + command: string; + /** Trailing clause of the instruction: "retry" after a failure, "send again" before one. */ + retryHint?: string; + errorText?: ReactNode; + extraActions?: ReactNode; + onRunSignIn?: (() => void) | undefined; + onDismiss?: (() => void) | undefined; +}) { + return ( + + Run {command}, complete the browser + sign-in, then {retryHint}. + + } + {...(errorText !== undefined ? { errorText } : {})} + actions={ + <> + + {extraActions} + {onDismiss ? : null} + + } + /> + ); +} + +/** + * Same shape for a provider whose CLI is missing. There is no terminal command + * we can run for the user here, so the action routes to the place that lists + * install and sign-in steps. + */ +export function ProviderNotInstalledNotice({ + providerLabel, + extraActions, + onDismiss, +}: { + providerLabel: string; + extraActions?: ReactNode; + onDismiss?: (() => void) | undefined; +}) { + return ( + + + {extraActions} + {onDismiss ? : null} + + } + /> + ); +} + +/** + * Shown in place of a turn we held back. Nothing is ever hard-blocked: the + * snapshot behind the verdict can be stale, so "Send anyway" dispatches the + * turn exactly as an uninterrupted send would. + */ +export function ProviderSendPreflightNotice({ + prompt, + onRunSignIn, + onSendAnyway, + onDismiss, +}: { + prompt: ProviderSendPreflightPrompt | null; + onRunSignIn: (prompt: ProviderSendPreflightPrompt) => void; + onSendAnyway: () => void; + onDismiss: () => void; +}) { + if (!prompt) { + return null; + } + + const sendAnyway = ( + + ); + + // A provider that isn't installed has nothing to sign in to yet, and one + // without a known login command has no terminal step we can name, so both + // fall back to the install-and-connect card. + if (prompt.reason === "notInstalled" || !prompt.command) { + return ( + + ); + } + + return ( + onRunSignIn(prompt)} + onDismiss={onDismiss} + /> + ); +} diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx index d80c0d77e..c283c676d 100644 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ b/apps/web/src/components/chat/ThreadErrorBanner.tsx @@ -3,7 +3,8 @@ import type { ProviderAuthReconnectAction } from "../../session-logic"; import { formatProviderRateLimitResetCreditTooltip } from "../ProviderRateLimitResetCredit"; import { Button } from "../ui/button"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; -import { RefreshCwIcon, RotateCcwIcon, TerminalIcon, XIcon } from "lucide-react"; +import { RefreshCwIcon, RotateCcwIcon } from "lucide-react"; +import { DismissNoticeButton, ProviderSignInNotice } from "./ProviderReadinessNotice"; import { CompactStatusNoticeRow } from "./statusNotice"; type UsageResetAction = { @@ -17,19 +18,6 @@ type TurnRetryAction = { onRetry: () => void; }; -function DismissErrorButton({ onDismiss }: { onDismiss: () => void }) { - return ( - - ); -} - export const ThreadErrorBanner = memo(function ThreadErrorBanner({ error, authReconnect, @@ -50,31 +38,13 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ if (!error) return null; if (authReconnect) { - const label = providerLabel?.trim() || "Provider"; return ( - - Run {authReconnect.command}, - complete the browser sign-in, then retry. - - } + Last error: {error}} - actions={ - <> - - {onDismiss ? : null} - - } + onRunSignIn={onRunAuthReconnect ? () => onRunAuthReconnect(authReconnect) : undefined} + {...(onDismiss ? { onDismiss } : {})} /> ); } @@ -121,7 +91,7 @@ export const ThreadErrorBanner = memo(function ThreadErrorBanner({ ) : null} - {onDismiss ? : null} + {onDismiss ? : null} } /> diff --git a/apps/web/src/components/settings/settingsNavigation.ts b/apps/web/src/components/settings/settingsNavigation.ts index 27f08f50d..9c6ab149a 100644 --- a/apps/web/src/components/settings/settingsNavigation.ts +++ b/apps/web/src/components/settings/settingsNavigation.ts @@ -12,6 +12,8 @@ import { import { SourceControlIcon } from "../Icons"; export const DEFAULT_SETTINGS_SECTION_PATH = "/settings/general" as const; +/** Where pairing lives, for surfaces that need to route a user to it. */ +export const DEVICES_SETTINGS_SECTION_PATH = "/settings/connections" as const; export const HOSTED_STATIC_DEFAULT_SETTINGS_SECTION_PATH = "/settings/general" as const; export const VISIBLE_SETTINGS_SECTION_PATHS = [ @@ -49,7 +51,7 @@ export const SETTINGS_NAV_ITEMS: ReadonlyArray<{ { label: "Plugins", to: "/settings/plugins", icon: PlugIcon }, { label: "Agent Instructions", to: "/settings/instructions", icon: FileTextIcon }, { label: "Source Control", to: "/settings/source-control", icon: SourceControlIcon }, - { label: "Devices", to: "/settings/connections", icon: SmartphoneIcon }, + { label: "Devices", to: DEVICES_SETTINGS_SECTION_PATH, icon: SmartphoneIcon }, { label: "Keybindings", to: "/settings/keybindings", icon: KeyboardIcon }, { label: "Archives", to: "/settings/archived", icon: ArchiveIcon }, ];