diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index cd0de4f87..90b426949 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -16,6 +16,7 @@ import { ProviderInstanceId, type ServerConfig, type ServerLifecycleWelcomePayload, + type ServerProvider, type ThreadId, type TurnId, WS_METHODS, @@ -2357,7 +2358,7 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("keeps dismiss-only composer banners aligned on mobile", async () => { + it("docks the version mismatch notice to the composer and dismisses it on mobile", async () => { const mounted = await mountChatView({ viewport: COMPACT_FOOTER_VIEWPORT, snapshot: createSnapshotForTargetUser({ @@ -2376,24 +2377,44 @@ describe("ChatView timeline estimator parity (full app)", () => { }); try { - const banner = await waitForElement( - () => - Array.from(document.querySelectorAll('[data-slot="alert"]')).find( - (element) => element.textContent?.includes("Client and server versions differ"), - ) ?? null, - "Unable to find version mismatch banner.", + const dock = await waitForElement( + () => document.querySelector('[data-composer-notice-dock="true"]'), + "Unable to find the composer notice dock.", ); - const title = banner.querySelector('[data-slot="alert-title"]'); - const description = banner.querySelector('[data-slot="alert-description"]'); - const dismissButton = banner.querySelector( - 'button[aria-label="Dismiss version mismatch warning"]', + expect(dock.textContent).toContain("Client and server versions differ."); + // Version skew is informational, so it never claims a louder colour. + expect( + dock + .querySelector("[data-composer-notice-severity]") + ?.getAttribute("data-composer-notice-severity"), + ).toBe("info"); + + const composerSurface = document.querySelector( + "[data-chat-composer-mobile-collapsed]", ); + expect(composerSurface).toBeTruthy(); + // Docked means attached: the notice sits directly on the composer's top + // edge, sharing its width. + expect( + Math.abs(dock.getBoundingClientRect().left - composerSurface!.getBoundingClientRect().left), + ).toBeLessThan(2); + expect( + Math.abs( + dock.getBoundingClientRect().bottom - composerSurface!.getBoundingClientRect().top, + ), + ).toBeLessThan(2); - expect(title).toBeTruthy(); - expect(description).toBeTruthy(); + const dismissButton = dock.querySelector( + 'button[aria-label="Dismiss version mismatch warning"]', + ); expect(dismissButton).toBeTruthy(); - expect(dismissButton!.getBoundingClientRect().top).toBeLessThan( - description!.getBoundingClientRect().top, + dismissButton!.click(); + + await vi.waitFor( + () => { + expect(document.querySelector('[data-composer-notice-dock="true"]')).toBeNull(); + }, + { timeout: 8_000, interval: 16 }, ); } finally { await mounted.cleanup(); @@ -3740,27 +3761,40 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); - it("holds back a send to a signed-out provider until the user chooses Send anyway", async () => { + async function mountSignedOutProviderSend(options: { + /** Providers the recheck behind "I've signed in" resolves with. */ + refreshedProviders: (signedOut: ServerProvider) => ReadonlyArray; + }) { setDraftThreadWithoutWorktree(); + let signedOutProvider: ServerProvider | null = null; const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, snapshot: createDraftOnlySnapshot(), configureFixture: (nextFixture) => { + signedOutProvider = { + ...nextFixture.serverConfig.providers[0]!, + status: "warning", + auth: { status: "unauthenticated" }, + }; nextFixture.serverConfig = { ...nextFixture.serverConfig, - providers: [ - { - ...nextFixture.serverConfig.providers[0]!, - status: "warning", - auth: { status: "unauthenticated" }, - }, - ], + providers: [signedOutProvider], }; }, - resolveRpc: (body) => - body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand - ? { sequence: fixture.snapshot.snapshotSequence + 1 } - : undefined, + resolveRpc: (body) => { + if (body._tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + return { sequence: fixture.snapshot.snapshotSequence + 1 }; + } + if (body._tag === WS_METHODS.serverRefreshProviders) { + return { + providers: encodeServerConfig({ + ...fixture.serverConfig, + providers: options.refreshedProviders(signedOutProvider!), + }).providers, + }; + } + return undefined; + }, }); const turnStartRequests = () => @@ -3770,21 +3804,31 @@ describe("ChatView timeline estimator parity (full app)", () => { request.type === "thread.turn.start", ); - try { - useComposerDraftStore.getState().setPrompt(THREAD_REF, "Explain this repo"); - await waitForLayout(); + useComposerDraftStore.getState().setPrompt(THREAD_REF, "Explain this repo"); + await waitForLayout(); - (await waitForSendButton()).click(); + (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", - ); + const confirmSignedIn = await waitForButtonByText("I've signed in"); + // The turn never left the client, and the draft survived the interruption. + expect(turnStartRequests()).toHaveLength(0); + expect(document.body.textContent).toContain("Codex needs sign-in."); + expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt).toBe( + "Explain this repo", + ); + + return { confirmSignedIn, mounted, turnStartRequests }; + } + + it("sends the held message once the recheck behind I've signed in comes back clean", async () => { + const { confirmSignedIn, mounted, turnStartRequests } = await mountSignedOutProviderSend({ + refreshedProviders: (signedOut) => [ + { ...signedOut, status: "ready", auth: { status: "authenticated" } }, + ], + }); - sendAnyway.click(); + try { + confirmSignedIn.click(); await vi.waitFor( () => { @@ -3792,6 +3836,33 @@ describe("ChatView timeline estimator parity (full app)", () => { }, { timeout: 8_000, interval: 16 }, ); + // The recheck is not a bypass: exactly one turn, through the normal gate. + await waitForLayout(); + expect(turnStartRequests()).toHaveLength(1); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps holding the message when the recheck still reports a signed-out provider", async () => { + const { confirmSignedIn, mounted, turnStartRequests } = await mountSignedOutProviderSend({ + refreshedProviders: (signedOut) => [signedOut], + }); + + try { + confirmSignedIn.click(); + + await vi.waitFor( + () => { + expect(document.body.textContent).toContain("Still signed out."); + }, + { timeout: 8_000, interval: 16 }, + ); + expect(turnStartRequests()).toHaveLength(0); + expect(document.body.textContent).toContain("The terminal shows where the sign-in stopped."); + expect(useComposerDraftStore.getState().draftsByThreadKey[THREAD_KEY]?.prompt).toBe( + "Explain this repo", + ); } finally { await mounted.cleanup(); } @@ -8378,7 +8449,6 @@ describe("ChatView timeline estimator parity (full app)", () => { const actions = document.querySelector( '[data-chat-composer-actions="right"]', ); - expect(footer?.dataset.chatComposerFooterCompact).toBe("true"); expect(actions?.dataset.chatComposerPrimaryActionsCompact).toBe("true"); }, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index f8a311701..00771d498 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -45,7 +45,7 @@ import { resolveSendEnvMode, shouldConfirmTerminalKill, shouldOfferFailedTurnRetry, - shouldRenderThreadErrorBanner, + shouldShowThreadErrorNotice, shouldRefreshThreadDetailAfterEventLoopStall, shouldWriteThreadErrorToCurrentServerThread, THREAD_DETAIL_STALL_REFRESH_COOLDOWN_MS, @@ -517,10 +517,10 @@ describe("deriveProviderAuthReconnectPrompt", () => { }); }); -describe("shouldRenderThreadErrorBanner", () => { +describe("shouldShowThreadErrorNotice", () => { it("hides provider authentication errors already rendered in the timeline", () => { expect( - shouldRenderThreadErrorBanner({ + shouldShowThreadErrorNotice({ threadError: "Your access token could not be refreshed because your refresh token was revoked.", hasInlineProviderAuthError: true, @@ -530,7 +530,7 @@ describe("shouldRenderThreadErrorBanner", () => { it("keeps provider authentication errors as a fallback when no inline recovery is visible", () => { expect( - shouldRenderThreadErrorBanner({ + shouldShowThreadErrorNotice({ threadError: "Not logged in. Run `codex login` in a terminal, then retry.", hasInlineProviderAuthError: false, }), @@ -539,7 +539,7 @@ describe("shouldRenderThreadErrorBanner", () => { it("keeps unrelated thread errors visible even when the timeline contains an auth error", () => { expect( - shouldRenderThreadErrorBanner({ + shouldShowThreadErrorNotice({ threadError: "Could not stop the background process.", hasInlineProviderAuthError: true, }), diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index af4a28edd..3d0d97208 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1482,9 +1482,10 @@ export interface ProviderSendPreflightPrompt { * 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. + * front. Provider snapshots go stale, so the hold is never final: the caller + * offers a recheck that re-probes the provider and, on a clean answer, sends + * the held turn through this same gate. An `available` verdict (which includes + * an auth state we cannot judge) never interrupts anything. */ export function deriveProviderSendPreflight(input: { readonly instanceId: ProviderInstanceId | null | undefined; @@ -1514,7 +1515,7 @@ export function deriveProviderSendPreflight(input: { }; } -export function shouldRenderThreadErrorBanner(input: { +export function shouldShowThreadErrorNotice(input: { readonly threadError: string | null | undefined; readonly hasInlineProviderAuthError: boolean; }): boolean { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7af1543e0..1ae71af05 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -126,7 +126,7 @@ import { import { buildTemporaryWorktreeBranchName } from "@threadlines/shared/git"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand, shortcutLabelForCommand } from "../keybindings"; -import { ChevronDownIcon, CornerDownRightIcon, TriangleAlertIcon, WifiOffIcon } from "lucide-react"; +import { ChevronDownIcon, CornerDownRightIcon } from "lucide-react"; import { cn, randomUUID } from "~/lib/utils"; import { markThreadSeen, selectThreadLastSeenAt } from "~/lib/threadInboxSync"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; @@ -208,13 +208,13 @@ import { FilePreviewDialog, type FilePreviewRequest } from "./chat/FilePreviewDi import { NoActiveThreadState } from "./NoActiveThreadState"; import { resolveEffectiveEnvMode, resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { - ProviderStatusBanner, - 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"; + shouldShowProviderStatusNotice, + useProviderStatusNotice, +} from "./chat/providerStatusNotice"; +import { useSessionStartupNotice } from "./chat/sessionStartupNotice"; +import { buildProviderSendPreflightNotice } from "./chat/providerReadinessNotice"; +import { buildThreadErrorNotice } from "./chat/threadErrorNotice"; +import { type ComposerNotice, selectComposerNotices } from "./chat/composerNotices"; import { MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, DEFAULT_SCROLL_END_TOLERANCE_PX, @@ -233,7 +233,7 @@ import { hasServerAcknowledgedLocalDispatch, isRetryableThreadError, isScrollMetricsAtEnd, - shouldRenderThreadErrorBanner, + shouldShowThreadErrorNotice, scrollMetricsDistanceFromEnd, deriveTimelineScrolledFarFromEnd, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, @@ -269,6 +269,7 @@ import { PreviewAutomationMount } from "./browser/PreviewAutomationMount"; import { BrowserSplitHandle } from "./browser/BrowserSplitHandle"; import { useComposerHandleContext } from "../composerHandleContext"; import { + applyProvidersUpdated, useServerAvailableEditors, useServerConfig, useServerKeybindings, @@ -1687,54 +1688,41 @@ export default function ChatView(props: ChatViewProps) { savedEnvironmentRuntimeById, serverConfig?.environment.label, ]); - const infrastructureComposerBannerItems = useMemo(() => { - const items: ComposerBannerStackItem[] = []; + const infrastructureComposerNotices = useMemo(() => { + const items: ComposerNotice[] = []; if (activeEnvironmentUnavailableState) { + const isConnecting = activeEnvironmentUnavailableState.connectionState === "connecting"; + const isReconnecting = + isConnecting || + reconnectingEnvironmentId === activeEnvironmentUnavailableState.environmentId; items.push({ id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: + severity: activeEnvironmentUnavailableState.connectionState === "error" ? "error" : "warning", - icon: , - title: ( - <> - {activeEnvironmentUnavailableState.label} is{" "} - {activeEnvironmentUnavailableState.connectionState === "connecting" - ? "connecting" - : "disconnected"} - - ), - description: "Reconnect this environment before sending messages or running actions.", + lead: `${activeEnvironmentUnavailableState.label} is ${isConnecting ? "connecting" : "disconnected"}.`, + detail: "Reconnect this environment before sending messages or running actions.", actions: ( - <> - - + ), }); } if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { items.push({ id: `version-mismatch:${versionMismatchDismissKey}`, - variant: "warning", - icon: , - title: "Client and server versions differ", - description: ( + severity: "info", + lead: "Client and server versions differ.", + detail: ( <> Client {versionMismatch.clientVersion} is connected to {versionMismatchServerLabel}{" "} {versionMismatch.serverVersion}. Sync them if RPC calls or reconnects fail. @@ -1758,6 +1746,11 @@ export default function ChatView(props: ChatViewProps) { versionMismatchServerLabel, ]); const providerStatuses = serverConfig?.providers ?? EMPTY_PROVIDERS; + // A send preflight has to read the freshest snapshot we hold, including one + // a recheck wrote moments ago inside the same event, before React has + // re-rendered with it. + const providerStatusesRef = useRef(providerStatuses); + providerStatusesRef.current = providerStatuses; const providerInstanceEntries = useMemo>( () => filterMaintainedProviderInstanceEntries( @@ -2487,16 +2480,23 @@ export default function ChatView(props: ChatViewProps) { ], ); // 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. + // already known to be unusable. Cleared by dismissing the notice, by a clean + // recheck, and by any later send that preflights clean. const [providerSendPreflight, setProviderSendPreflight] = useState(null); + const [providerSendPreflightRecheckFailed, setProviderSendPreflightRecheckFailed] = + useState(false); + const [isRecheckingProviderSendPreflight, setIsRecheckingProviderSendPreflight] = useState(false); + // The recheck runs the ordinary send path, which is rebuilt every render. + // Holding it behind a ref keeps the notice itself stable. + const confirmProviderSignedInRef = useRef<() => void>(() => {}); useEffect(() => { // The notice belongs to the send it interrupted, so it must not follow the // user into another thread. setProviderSendPreflight(null); + setProviderSendPreflightRecheckFailed(false); }, [activeThreadKey]); - const providerStatusBannerVisible = shouldRenderProviderStatusBanner(activeProviderStatus, { + const providerStatusNoticeVisible = shouldShowProviderStatusNotice(activeProviderStatus, { activeTurnInProgress, }); const hasInlineProviderAuthError = useMemo( @@ -2508,7 +2508,7 @@ export default function ChatView(props: ChatViewProps) { )), [providerAuthReconnectPrompt, timelineMessages, workLogEntries], ); - const threadErrorBannerVisible = shouldRenderThreadErrorBanner({ + const threadErrorNoticeVisible = shouldShowThreadErrorNotice({ threadError: activeThread?.error, hasInlineProviderAuthError, }); @@ -3498,8 +3498,6 @@ export default function ChatView(props: ChatViewProps) { ], ); - const composerBannerItems = infrastructureComposerBannerItems; - const persistProjectScripts = useCallback( async (input: { projectId: ProjectId; @@ -3874,11 +3872,12 @@ export default function ChatView(props: ChatViewProps) { }; }, [ activeThread?.id, - composerBannerItems.length, - providerStatusBannerVisible, + infrastructureComposerNotices.length, + providerSendPreflight, + providerStatusNoticeVisible, terminalState.terminalHeight, terminalState.terminalOpen, - threadErrorBannerVisible, + threadErrorNoticeVisible, ]); useEffect(() => { @@ -4283,10 +4282,7 @@ export default function ChatView(props: ChatViewProps) { setThreadError, ]); - const onSend = async ( - e?: { preventDefault: () => void }, - options?: { readonly skipProviderPreflight?: boolean }, - ) => { + const onSend = async (e?: { preventDefault: () => void }) => { e?.preventDefault(); const api = readEnvironmentApi(environmentId); const activeSteerTurnId = @@ -4410,16 +4406,15 @@ export default function ChatView(props: ChatViewProps) { } // 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; - } + // returns the user to exactly what they typed. The ref, not the render + // value, is what a recheck-then-send has just written to. + const preflight = deriveProviderSendPreflight({ + instanceId: ctxSelectedModelSelection.instanceId, + providers: providerStatusesRef.current, + }); + if (preflight) { + setProviderSendPreflight(preflight); + return; } setProviderSendPreflight(null); if (!activeProject) return; @@ -4771,6 +4766,47 @@ export default function ChatView(props: ChatViewProps) { } }; + /** + * "I've signed in" is a claim we can check. It re-probes the provider, folds + * the answer into the app's snapshot, and — when the answer agrees — sends + * the held message down the ordinary path, gate and all. Nothing bypasses + * the preflight, so a user who guessed wrong gets told rather than dropped + * into the reconnect noise the gate exists to prevent. + */ + const onConfirmProviderSignedIn = async (): Promise => { + const prompt = providerSendPreflight; + if (!prompt || isRecheckingProviderSendPreflight) { + return; + } + setIsRecheckingProviderSendPreflight(true); + try { + const payload = await ensureLocalApi().server.refreshProviders({ + instanceId: prompt.instanceId, + }); + applyProvidersUpdated(payload); + providerStatusesRef.current = payload.providers; + const stillUnusable = deriveProviderSendPreflight({ + instanceId: prompt.instanceId, + providers: payload.providers, + }); + if (stillUnusable) { + setProviderSendPreflight(stillUnusable); + setProviderSendPreflightRecheckFailed(true); + return; + } + setProviderSendPreflight(null); + setProviderSendPreflightRecheckFailed(false); + await onSend(); + } catch { + setProviderSendPreflightRecheckFailed(true); + } finally { + setIsRecheckingProviderSendPreflight(false); + } + }; + confirmProviderSignedInRef.current = () => { + void onConfirmProviderSignedIn(); + }; + const onInterrupt = async () => { const api = readEnvironmentApi(environmentId); if (!api || !activeThread) return; @@ -5117,6 +5153,88 @@ export default function ChatView(props: ChatViewProps) { turnRetryDispatchingThreadId, ]); + const providerStatusNotice = useProviderStatusNotice({ + status: activeProviderStatus, + activeTurnInProgress, + suppressed: + providerSendPreflight !== null && + providerSendPreflight.instanceId === activeProviderStatus?.instanceId, + }); + const sessionStartupNotice = useSessionStartupNotice({ + isSessionStarting, + startedAt: activeWorkStartedAt, + suppressed: providerStatusNoticeVisible || threadErrorNoticeVisible, + providerStatus: activeProviderStatus, + }); + const threadErrorNotice = useMemo( + () => + buildThreadErrorNotice({ + error: threadErrorNoticeVisible ? (activeThread?.error ?? null) : null, + authReconnect: providerAuthReconnectPrompt, + usageReset: threadErrorUsageResetAction, + retry: threadErrorRetryAction, + providerLabel: activeProviderLabel, + onRunAuthReconnect: runProviderAuthReconnect, + onDismiss: () => setThreadError(activeThread?.id ?? null, null), + }), + [ + activeProviderLabel, + activeThread?.error, + activeThread?.id, + providerAuthReconnectPrompt, + runProviderAuthReconnect, + setThreadError, + threadErrorNoticeVisible, + threadErrorRetryAction, + threadErrorUsageResetAction, + ], + ); + const sendPreflightNotice = useMemo( + () => + providerSendPreflight + ? buildProviderSendPreflightNotice({ + prompt: providerSendPreflight, + recheckFailed: providerSendPreflightRecheckFailed, + isRechecking: isRecheckingProviderSendPreflight, + onRunSignIn: (prompt) => { + void runProviderAuthReconnect({ + provider: prompt.provider, + command: prompt.command ?? "", + message: `${prompt.providerLabel} is not signed in.`, + }); + }, + onConfirmSignedIn: () => confirmProviderSignedInRef.current(), + onDismiss: () => { + setProviderSendPreflight(null); + setProviderSendPreflightRecheckFailed(false); + }, + }) + : null, + [ + isRecheckingProviderSendPreflight, + providerSendPreflight, + providerSendPreflightRecheckFailed, + runProviderAuthReconnect, + ], + ); + const composerNotices = useMemo( + () => + selectComposerNotices([ + threadErrorNotice, + sendPreflightNotice, + providerStatusNotice, + sessionStartupNotice, + ...infrastructureComposerNotices, + ]), + [ + infrastructureComposerNotices, + providerStatusNotice, + sendPreflightNotice, + sessionStartupNotice, + threadErrorNotice, + ], + ); + const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { const api = readEnvironmentApi(environmentId); @@ -6205,41 +6323,6 @@ export default function ChatView(props: ChatViewProps) { /> - {/* Error banner */} - - - 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}
-
{children}, @@ -33,7 +36,13 @@ function renderWithTestRouter(children: ReactNode) { } describe("HostedStaticOnboardingState", () => { - it("offers a download and a route to pairing instead of dead-ending", async () => { + afterEach(async () => { + await page.viewport(DESKTOP_VIEWPORT.width, DESKTOP_VIEWPORT.height); + document.body.innerHTML = ""; + }); + + it("offers a download and a route to pairing on a desktop browser", async () => { + await page.viewport(DESKTOP_VIEWPORT.width, DESKTOP_VIEWPORT.height); renderWithTestRouter(); await expect @@ -43,4 +52,27 @@ describe("HostedStaticOnboardingState", () => { .element(page.getByRole("link", { name: "Pair this browser" })) .toHaveAttribute("href", "/settings/connections"); }); + + it("walks a phone through pairing instead of offering it a desktop download", async () => { + await page.viewport(PHONE_VIEWPORT.width, PHONE_VIEWPORT.height); + renderWithTestRouter(); + + await expect.element(page.getByText("Pair with your computer")).toBeVisible(); + await expect + .element(page.getByText("Threadlines runs on your computer; this phone connects to it.")) + .toBeVisible(); + await expect + .element(page.getByRole("link", { name: "threadlines.dev/download" })) + .toHaveAttribute("href", "https://threadlines.dev/download"); + await expect.element(page.getByText("Settings → Devices → Add device")).toBeVisible(); + await expect + .element(page.getByText("Scan the QR code it shows with this phone’s camera")) + .toBeVisible(); + await expect + .element(page.getByRole("link", { name: "I have a setup link" })) + .toHaveAttribute("href", "/settings/connections"); + await expect + .element(page.getByRole("link", { name: "Download the desktop app" })) + .not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/components/HostedStaticStatusStates.tsx b/apps/web/src/components/HostedStaticStatusStates.tsx index 9c6a35efd..62e123e26 100644 --- a/apps/web/src/components/HostedStaticStatusStates.tsx +++ b/apps/web/src/components/HostedStaticStatusStates.tsx @@ -9,8 +9,9 @@ import { } from "lucide-react"; import { type ReactNode } from "react"; -import { APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "../branding"; +import { APP_BASE_NAME, APP_DISPLAY_NAME, DESKTOP_DOWNLOAD_URL } from "../branding"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../workspaceTitlebar"; +import { useMediaQuery } from "../hooks/useMediaQuery"; import { cn } from "../lib/utils"; import { DEVICES_SETTINGS_SECTION_PATH } from "./settings/settingsNavigation"; import { Button } from "./ui/button"; @@ -27,12 +28,15 @@ function HostedStaticStatusState({ title, description, detail, + body, action, }: { icon: ReactNode; title: string; description: string; detail?: string; + /** Full-width content between the description and the action. */ + body?: ReactNode; action?: ReactNode; }) { return ( @@ -67,6 +71,7 @@ function HostedStaticStatusState({ {detail}

) : null} + {body} {action ?
{action}
: null}
@@ -121,14 +126,83 @@ export function HostedStaticConnectionErrorState({ ); } +const PAIRING_STEPS = [ + { + id: "install", + body: ( + <> + On your computer, install {APP_BASE_NAME} from{" "} + + threadlines.dev/download + + + ), + }, + { + id: "add-device", + body: ( + <> + In the desktop app, open{" "} + Settings → Devices → Add device + + ), + }, + { + id: "scan", + body: <>Scan the QR code it shows with this phone’s camera, + }, +] as const; + /** - * 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. + * What a phone that has never paired sees. + * + * A phone cannot install the desktop app on itself, so there is no download + * button here: its job is to receive a pairing, and the three steps name where + * that pairing is minted. Scanning the QR code opens this page with the token + * already filled in, so the one action is for people whose computer is in + * another room. */ -export function HostedStaticOnboardingState() { +function HostedStaticPhoneOnboardingState() { + return ( + } + title="Pair with your computer" + description={`${APP_BASE_NAME} runs on your computer; this phone connects to it.`} + body={ +
    + {PAIRING_STEPS.map((step, index) => ( +
  1. + {index + 1} + {step.body} +
  2. + ))} +
+ } + action={ + + } + /> + ); +} + +/** + * The same cold start on a computer, where installing the desktop app is + * something this browser's machine can actually do. 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. + */ +function HostedStaticDesktopOnboardingState() { return ( } @@ -155,3 +229,12 @@ export function HostedStaticOnboardingState() { /> ); } + +export function HostedStaticOnboardingState() { + const isPhoneViewport = useMediaQuery("max-sm"); + return isPhoneViewport ? ( + + ) : ( + + ); +} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index ec61d0876..792cc2b89 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -106,6 +106,8 @@ import { ComposerPendingApprovalPanel } from "./ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./ComposerPendingUserInputPanel"; import { ComposerGoalBar, type ComposerGoalSetInput } from "./ComposerGoalBar"; import { ComposerPlanFollowUpBanner } from "./ComposerPlanFollowUpBanner"; +import type { ComposerNotice } from "./composerNotices"; +import { ComposerNoticeDock } from "./ComposerNoticeDock"; import { ComposerPendingDrawingContexts } from "./ComposerPendingDrawingContexts"; import { ComposerPendingPickedElementContexts } from "./ComposerPendingPickedElementContexts"; import { ComposerPendingTranscriptHighlightContexts } from "./ComposerPendingTranscriptHighlightContexts"; @@ -524,6 +526,13 @@ export interface ChatComposerProps { // Context window activeThreadActivities: Thread["activities"] | undefined; + /** + * Active send-blocking notices, worst first. The dock renders the first one + * attached to the composer's top edge, so a non-empty list also squares the + * composer's top corners. + */ + notices: ReadonlyArray; + // Misc resolvedTheme: "light" | "dark"; settings: UnifiedSettings; @@ -629,6 +638,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) activeProjectDefaultModelSelection, activeThreadModelSelection, activeThreadActivities, + notices, resolvedTheme, settings, keybindings, @@ -3072,11 +3082,13 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onDragLeave={onComposerDragLeave} onDrop={onComposerDrop} > +
0 && "rounded-t-none", isDragOverComposer ? "border-primary/70 bg-accent/30" : "border-border", environmentUnavailable ? "opacity-75" : null, composerProviderState.composerSurfaceClassName, diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx deleted file mode 100644 index 5f00d1897..000000000 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ /dev/null @@ -1,195 +0,0 @@ -import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react"; -import { XIcon } from "lucide-react"; - -import { cn } from "~/lib/utils"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; -import { Button } from "../ui/button"; - -const DISMISS_TRANSITION_MS = 220; -const frontExitStyle = { - opacity: 0, - transform: "translate3d(0, 4rem, 0)", -} satisfies CSSProperties; -const stackedExitStyle = { - opacity: 0, - transform: "translate3d(0, 7rem, 0)", -} satisfies CSSProperties; -const restingStyle = { - opacity: 1, - transform: "translate3d(0, 0, 0)", -} satisfies CSSProperties; -const exitTransitionStyle = { - transition: `transform ${DISMISS_TRANSITION_MS}ms ease-in, opacity ${DISMISS_TRANSITION_MS}ms ease-in`, - willChange: "transform, opacity", -} satisfies CSSProperties; - -export interface ComposerBannerStackItem { - readonly id: string; - readonly variant: "error" | "info" | "success" | "warning"; - readonly icon: ReactNode; - readonly title: ReactNode; - readonly description?: ReactNode; - readonly actions?: ReactNode; - readonly dismissLabel?: string; - readonly onDismiss?: () => void; -} - -interface ComposerBannerStackProps { - readonly className?: string; - readonly items: ReadonlyArray; -} - -export function ComposerBannerStack({ className, items }: ComposerBannerStackProps) { - const [exitingItemId, setExitingItemId] = useState(null); - const dismissTimeoutRef = useRef | null>(null); - - useEffect(() => { - if (exitingItemId && !items.some((item) => item.id === exitingItemId)) { - setExitingItemId(null); - } - }, [exitingItemId, items]); - - useEffect(() => { - return () => { - if (dismissTimeoutRef.current) { - clearTimeout(dismissTimeoutRef.current); - } - }; - }, []); - - if (items.length === 0) { - return null; - } - - const frontItem = items[0]; - if (!frontItem) { - return null; - } - const stackedItems = items.slice(1); - const hasStack = stackedItems.length > 0; - const showCollapsedStackCap = hasStack && exitingItemId !== frontItem.id; - - const requestDismiss = (item: ComposerBannerStackItem) => { - if (!item.onDismiss || exitingItemId) { - return; - } - setExitingItemId(item.id); - if (dismissTimeoutRef.current) { - clearTimeout(dismissTimeoutRef.current); - } - dismissTimeoutRef.current = setTimeout(() => { - dismissTimeoutRef.current = null; - item.onDismiss?.(); - }, DISMISS_TRANSITION_MS); - }; - - return ( -
-
- {showCollapsedStackCap ? ( - -
- ); -} - -function ComposerBannerStackAlert({ - item, - exiting, - onDismissRequest, -}: { - readonly item: ComposerBannerStackItem; - readonly exiting: boolean; - readonly onDismissRequest: () => void; -}) { - const dismissOnly = item.onDismiss && !item.actions; - - return ( - - {item.icon} - {item.title} - {item.description ? {item.description} : null} - {item.actions || item.onDismiss ? ( - - {item.actions} - {item.onDismiss ? ( - - ) : null} - - ) : null} - - ); -} diff --git a/apps/web/src/components/chat/ComposerNoticeDock.tsx b/apps/web/src/components/chat/ComposerNoticeDock.tsx new file mode 100644 index 000000000..0b813e7fd --- /dev/null +++ b/apps/web/src/components/chat/ComposerNoticeDock.tsx @@ -0,0 +1,134 @@ +/** + * The notice row docked to the top of the composer. + * + * It shares the composer's left and right edges and squares off its top + * corners, so it reads as a statement about sending rather than as another + * piece of chat content. Only the worst active notice is on screen; the rest + * sit behind a count that expands them in place. + * + * @module ComposerNoticeDock + */ +import { ChevronDownIcon, XIcon } from "lucide-react"; +import { useEffect, useRef, useState, type ReactNode } from "react"; + +import { cn } from "~/lib/utils"; +import type { ComposerNotice, ComposerNoticeSeverity } from "./composerNotices"; + +const SEVERITY_DOT_CLASS: Record = { + error: "bg-destructive", + info: "bg-muted-foreground/55", + warning: "bg-warning", +}; + +export function ComposerNoticeDock({ notices }: { notices: ReadonlyArray }) { + const [isExpanded, setIsExpanded] = useState(false); + const dockRef = useRef(null); + const hiddenCount = Math.max(0, notices.length - 1); + + useEffect(() => { + if (hiddenCount === 0) { + setIsExpanded(false); + } + }, [hiddenCount]); + + useEffect(() => { + if (!isExpanded) { + return; + } + const onPointerDown = (event: PointerEvent) => { + const target = event.target; + if (target instanceof Node && dockRef.current?.contains(target)) { + return; + } + setIsExpanded(false); + }; + document.addEventListener("pointerdown", onPointerDown, true); + return () => { + document.removeEventListener("pointerdown", onPointerDown, true); + }; + }, [isExpanded]); + + const frontNotice = notices[0]; + if (!frontNotice) { + return null; + } + const stackedNotices = isExpanded ? notices.slice(1) : []; + + return ( + // The dock is always exactly as wide as the composer it docks to, so its + // inline size is contained: without that, the row's fixed chrome raises + // the composer's minimum width and can hold its footer out of the compact + // layout that narrow widths depend on. +
+ {stackedNotices.map((notice) => ( + + ))} + 0 ? ( + + ) : null + } + /> +
+ ); +} + +function ComposerNoticeRow({ + notice, + divided = false, + expander = null, +}: { + notice: ComposerNotice; + divided?: boolean; + expander?: ReactNode; +}) { + return ( +
+
+ ); +} diff --git a/apps/web/src/components/chat/ProviderReadinessNotice.tsx b/apps/web/src/components/chat/ProviderReadinessNotice.tsx deleted file mode 100644 index e8438db0c..000000000 --- a/apps/web/src/components/chat/ProviderReadinessNotice.tsx +++ /dev/null @@ -1,165 +0,0 @@ -/** - * 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/ProviderStatusBanner.tsx b/apps/web/src/components/chat/ProviderStatusBanner.tsx deleted file mode 100644 index a7b605074..000000000 --- a/apps/web/src/components/chat/ProviderStatusBanner.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { ProviderDriverKind, type ServerProvider } from "@threadlines/contracts"; -import { memo, useEffect, useState } from "react"; -import { Alert, AlertAction, AlertDescription, AlertTitle } from "../ui/alert"; -import { CircleAlertIcon } from "lucide-react"; -import { formatProviderDriverKindLabel } from "../../providerModels"; -import { - CompactStatusNoticeRow, - StatusNoticeActionButtons, - useProviderStatusRefresh, -} from "./statusNotice"; - -const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); -export const PROVIDER_STATUS_SLOW_NOTICE_DELAY_MS = 25_000; - -type ProviderStatusNoticeKind = "hidden" | "compact" | "alert"; - -function isCodexProviderProbeStatus(status: ServerProvider): boolean { - return ( - status.driver === CODEX_DRIVER_KIND && - (status.statusReason === "provider_probe_pending" || - status.statusReason === "provider_probe_timeout") - ); -} - -function providerStatusAgeMs(status: ServerProvider, nowMs: number): number { - const checkedAtMs = Date.parse(status.checkedAt); - if (!Number.isFinite(checkedAtMs)) { - return PROVIDER_STATUS_SLOW_NOTICE_DELAY_MS; - } - return Math.max(0, nowMs - checkedAtMs); -} - -function getPendingProbeNoticeDelayMs(status: ServerProvider, nowMs: number): number { - if ( - status.status !== "warning" || - status.statusReason !== "provider_probe_pending" || - !isCodexProviderProbeStatus(status) - ) { - return 0; - } - return Math.max(0, PROVIDER_STATUS_SLOW_NOTICE_DELAY_MS - providerStatusAgeMs(status, nowMs)); -} - -export function getProviderStatusNoticeKind( - status: ServerProvider | null, - options?: { - readonly activeTurnInProgress?: boolean; - readonly nowMs?: number; - }, -): ProviderStatusNoticeKind { - if (!status || status.status === "ready" || status.status === "disabled") { - return "hidden"; - } - if (status.status === "error") { - return "alert"; - } - if (options?.activeTurnInProgress === true && status.status === "warning") { - return "hidden"; - } - if (isCodexProviderProbeStatus(status)) { - if ( - status.statusReason === "provider_probe_pending" && - getPendingProbeNoticeDelayMs(status, options?.nowMs ?? Date.now()) > 0 - ) { - return "hidden"; - } - return "compact"; - } - return "alert"; -} - -export function shouldRenderProviderStatusBanner( - status: ServerProvider | null, - options?: { - readonly activeTurnInProgress?: boolean; - readonly nowMs?: number; - }, -): boolean { - return getProviderStatusNoticeKind(status, options) !== "hidden"; -} - -export const ProviderStatusBanner = memo(function ProviderStatusBanner({ - activeTurnInProgress = false, - status, -}: { - activeTurnInProgress?: boolean; - status: ServerProvider | null; -}) { - const [nowMs, setNowMs] = useState(() => Date.now()); - const { isRefreshing, refreshError, refreshProvider } = useProviderStatusRefresh( - status?.instanceId ?? null, - ); - - useEffect(() => { - setNowMs(Date.now()); - }, [status?.checkedAt, status?.instanceId, status?.statusReason]); - - useEffect(() => { - if (!status || activeTurnInProgress) { - return; - } - const remainingMs = getPendingProbeNoticeDelayMs(status, nowMs); - if (remainingMs <= 0) { - return; - } - const timeoutId = window.setTimeout(() => { - setNowMs(Date.now()); - }, remainingMs + 50); - return () => { - window.clearTimeout(timeoutId); - }; - }, [activeTurnInProgress, nowMs, status]); - - if (!status) { - return null; - } - - const noticeKind = getProviderStatusNoticeKind(status, { - activeTurnInProgress, - nowMs, - }); - if (noticeKind === "hidden") { - return null; - } - - const providerLabel = status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); - const defaultMessage = - status.status === "error" - ? `${providerLabel} provider is unavailable.` - : `${providerLabel} provider has limited availability.`; - const title = `${providerLabel} provider status`; - const message = - status.statusReason === "provider_probe_pending" - ? `${providerLabel} status check is taking longer than usual. Existing sessions may still work.` - : (status.message ?? defaultMessage); - - if (noticeKind === "compact") { - return ( - - } - /> - ); - } - - return ( -
- - - {title} - - {message} - {refreshError ? {refreshError} : null} - - - - - -
- ); -}); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx b/apps/web/src/components/chat/ThreadErrorBanner.test.tsx deleted file mode 100644 index 063939e5c..000000000 --- a/apps/web/src/components/chat/ThreadErrorBanner.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { ProviderDriverKind } from "@threadlines/contracts"; -import { renderToStaticMarkup } from "react-dom/server"; -import { describe, expect, it } from "vite-plus/test"; - -import { ThreadErrorBanner } from "./ThreadErrorBanner"; - -describe("ThreadErrorBanner", () => { - it("renders provider auth recovery steps and terminal action", () => { - const markup = renderToStaticMarkup( - {}} - />, - ); - - expect(markup).toContain("Claude sign-in required"); - expect(markup).toContain("claude auth login"); - expect(markup).toContain("complete the browser sign-in"); - expect(markup).toContain("Sign in in terminal"); - expect(markup).toContain('data-status-notice-tone="error"'); - expect(markup).toContain('role="alert"'); - }); - - it("renders a Codex usage reset action for usage-limit errors", () => { - const markup = renderToStaticMarkup( - {}, - }} - />, - ); - - expect(markup).toContain("usage limit."); - expect(markup).toContain("Reset usage"); - expect(markup).toContain("Reset Codex usage"); - }); - - it("renders a retry action for retryable turn failures", () => { - const markup = renderToStaticMarkup( - {}, - }} - />, - ); - - expect(markup).toContain("ECONNRESET"); - expect(markup).toContain(">Retry<"); - expect(markup).toContain("Retry last message"); - }); - - it("disables the retry action while a retry is dispatching", () => { - const markup = renderToStaticMarkup( - {}, - }} - />, - ); - - expect(markup).toContain("Retrying"); - expect(markup).toContain("disabled"); - }); - - it("omits the retry action when no retry handler is provided", () => { - const markup = renderToStaticMarkup( - , - ); - - expect(markup).not.toContain("Retry last message"); - }); -}); diff --git a/apps/web/src/components/chat/ThreadErrorBanner.tsx b/apps/web/src/components/chat/ThreadErrorBanner.tsx deleted file mode 100644 index c283c676d..000000000 --- a/apps/web/src/components/chat/ThreadErrorBanner.tsx +++ /dev/null @@ -1,99 +0,0 @@ -import { memo } from "react"; -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 } from "lucide-react"; -import { DismissNoticeButton, ProviderSignInNotice } from "./ProviderReadinessNotice"; -import { CompactStatusNoticeRow } from "./statusNotice"; - -type UsageResetAction = { - availableCount: number; - isResetting?: boolean; - onReset: () => void; -}; - -type TurnRetryAction = { - isRetrying: boolean; - onRetry: () => void; -}; - -export const ThreadErrorBanner = memo(function ThreadErrorBanner({ - error, - authReconnect, - usageReset, - retry, - providerLabel, - onRunAuthReconnect, - onDismiss, -}: { - error: string | null; - authReconnect?: ProviderAuthReconnectAction | null; - usageReset?: UsageResetAction | null; - retry?: TurnRetryAction | null; - providerLabel?: string; - onRunAuthReconnect?: (action: ProviderAuthReconnectAction) => void; - onDismiss?: () => void; -}) { - if (!error) return null; - - if (authReconnect) { - return ( - Last error: {error}} - onRunSignIn={onRunAuthReconnect ? () => onRunAuthReconnect(authReconnect) : undefined} - {...(onDismiss ? { onDismiss } : {})} - /> - ); - } - - return ( - - {retry ? ( - - ) : null} - {usageReset ? ( - - - - - } - /> - - {formatProviderRateLimitResetCreditTooltip(usageReset.availableCount)} - - - ) : null} - {onDismiss ? : null} - - } - /> - ); -}); diff --git a/apps/web/src/components/chat/composerNotices.test.ts b/apps/web/src/components/chat/composerNotices.test.ts new file mode 100644 index 000000000..6aff92333 --- /dev/null +++ b/apps/web/src/components/chat/composerNotices.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { type ComposerNotice, selectComposerNotices } from "./composerNotices"; + +function notice(id: string, severity: ComposerNotice["severity"]): ComposerNotice { + return { id, lead: id, severity }; +} + +describe("selectComposerNotices", () => { + it("drops absent entries and keeps a single notice as-is", () => { + expect(selectComposerNotices([null, notice("thread-error", "error"), undefined])).toEqual([ + notice("thread-error", "error"), + ]); + }); + + it("puts the worst severity first so the dock always shows it", () => { + const selected = selectComposerNotices([ + notice("startup", "warning"), + notice("thread-error", "error"), + ]); + + expect(selected.map((entry) => entry.id)).toEqual(["thread-error", "startup"]); + }); + + it("keeps caller order between same-severity notices", () => { + const selected = selectComposerNotices([ + notice("preflight", "warning"), + notice("provider-status", "warning"), + ]); + + expect(selected.map((entry) => entry.id)).toEqual(["preflight", "provider-status"]); + }); + + it("holds back informational notices while something is actually wrong", () => { + const selected = selectComposerNotices([ + notice("version-mismatch", "info"), + notice("provider-status", "warning"), + ]); + + expect(selected.map((entry) => entry.id)).toEqual(["provider-status"]); + }); + + it("shows informational notices once the urgent ones clear", () => { + const selected = selectComposerNotices([notice("version-mismatch", "info")]); + + expect(selected.map((entry) => entry.id)).toEqual(["version-mismatch"]); + }); +}); diff --git a/apps/web/src/components/chat/composerNotices.ts b/apps/web/src/components/chat/composerNotices.ts new file mode 100644 index 000000000..f3e7a1b73 --- /dev/null +++ b/apps/web/src/components/chat/composerNotices.ts @@ -0,0 +1,55 @@ +/** + * The one notice surface above the composer. + * + * Provider status, turn errors, held sends, version skew and environment + * trouble all used to float their own box at the top of the chat and stack on + * each other. They are all statements about whether the next message can be + * sent, so they now share a single slim row docked to the composer, and this + * module decides which one that row shows. + * + * @module composerNotices + */ +import type { ReactNode } from "react"; + +export type ComposerNoticeSeverity = "error" | "warning" | "info"; + +export interface ComposerNotice { + readonly id: string; + readonly severity: ComposerNoticeSeverity; + /** Bold opening phrase naming the condition, e.g. "Codex needs sign-in." */ + readonly lead: string; + /** Plain continuation of the lead. Truncates with the row. */ + readonly detail?: ReactNode; + readonly actions?: ReactNode; + readonly dismissLabel?: string; + readonly onDismiss?: () => void; +} + +const SEVERITY_RANK: Record = { + error: 0, + warning: 1, + info: 2, +}; + +/** + * Orders active notices worst-first and drops the merely informational ones + * while something is actually wrong. + * + * Version skew and update availability are worth a line when the composer is + * otherwise calm, but behind a "1 more" expander on top of a failed turn they + * are just noise, so they wait for the urgent notice to clear instead. + * Ordering is stable, so same-severity notices keep the order the caller + * listed them in. + */ +export function selectComposerNotices( + notices: ReadonlyArray, +): ReadonlyArray { + const present = notices.filter((notice): notice is ComposerNotice => Boolean(notice)); + const hasUrgentNotice = present.some((notice) => notice.severity !== "info"); + const eligible = hasUrgentNotice + ? present.filter((notice) => notice.severity !== "info") + : present; + return eligible + .slice() + .sort((left, right) => SEVERITY_RANK[left.severity] - SEVERITY_RANK[right.severity]); +} diff --git a/apps/web/src/components/chat/providerReadinessNotice.tsx b/apps/web/src/components/chat/providerReadinessNotice.tsx new file mode 100644 index 000000000..7a863a62e --- /dev/null +++ b/apps/web/src/components/chat/providerReadinessNotice.tsx @@ -0,0 +1,167 @@ +/** + * The two ways an unusable provider reaches the composer notice dock. + * + * They arrive from two directions: after a turn already failed on a provider + * auth error (the thread-error notice) and before a send we can tell in + * advance will fail (ChatView's send preflight). Both directions have to read + * the same, so the copy lives here once instead of being restated per surface. + * + * @module providerReadinessNotice + */ +import { Link } from "@tanstack/react-router"; +import { SettingsIcon, TerminalIcon } from "lucide-react"; +import type { ReactNode } from "react"; + +import type { ProviderSendPreflightPrompt } from "../ChatView.logic"; +import { Button } from "../ui/button"; +import type { ComposerNotice } from "./composerNotices"; + +export function buildProviderSignInNotice({ + id, + severity = "error", + providerLabel, + command, + instruction = "complete the browser sign-in, then retry", + detailSuffix, + extraActions, + onRunSignIn, + onDismiss, +}: { + id: string; + severity?: ComposerNotice["severity"]; + providerLabel: string; + command: string; + /** Clause after the command: what happens once the sign-in lands. */ + instruction?: string; + detailSuffix?: string; + extraActions?: ReactNode; + onRunSignIn?: (() => void) | undefined; + onDismiss?: (() => void) | undefined; +}): ComposerNotice { + return { + id, + severity, + lead: `${providerLabel} needs sign-in.`, + detail: ( + <> + Run {command}, {instruction}. + {detailSuffix ? ` ${detailSuffix}` : null} + + ), + actions: ( + <> + + {extraActions} + + ), + dismissLabel: `Dismiss ${providerLabel} sign-in notice`, + ...(onDismiss ? { onDismiss } : {}), + }; +} + +/** + * 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 buildProviderNotInstalledNotice({ + id, + severity = "error", + providerLabel, + extraActions, + onDismiss, +}: { + id: string; + severity?: ComposerNotice["severity"]; + providerLabel: string; + extraActions?: ReactNode; + onDismiss?: (() => void) | undefined; +}): ComposerNotice { + return { + id, + severity, + lead: `${providerLabel} isn't installed.`, + detail: "Install it and sign in from Settings.", + actions: ( + <> + + {extraActions} + + ), + dismissLabel: `Dismiss ${providerLabel} install notice`, + ...(onDismiss ? { onDismiss } : {}), + }; +} + +/** + * Shown in place of a turn we held back. Nothing is ever hard-blocked: the + * snapshot behind the verdict can be stale, so "I've signed in" re-checks the + * provider and, when the fresh snapshot agrees, sends the held message down + * the ordinary path. + */ +export function buildProviderSendPreflightNotice({ + prompt, + recheckFailed, + isRechecking, + onRunSignIn, + onConfirmSignedIn, + onDismiss, +}: { + prompt: ProviderSendPreflightPrompt; + recheckFailed: boolean; + isRechecking: boolean; + onRunSignIn: (prompt: ProviderSendPreflightPrompt) => void; + onConfirmSignedIn: () => void; + onDismiss: () => void; +}): ComposerNotice { + // 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 notice. + if (prompt.reason === "notInstalled" || !prompt.command) { + return buildProviderNotInstalledNotice({ + id: "provider-send-preflight", + severity: "warning", + providerLabel: prompt.providerLabel, + onDismiss, + }); + } + + const confirmSignedIn = ( + + ); + + const notice = buildProviderSignInNotice({ + id: "provider-send-preflight", + severity: "warning", + providerLabel: prompt.providerLabel, + command: prompt.command, + instruction: "then your message sends", + extraActions: confirmSignedIn, + onRunSignIn: () => onRunSignIn(prompt), + onDismiss, + }); + + if (!recheckFailed) { + return notice; + } + + return { + ...notice, + lead: "Still signed out.", + detail: "The terminal shows where the sign-in stopped.", + }; +} diff --git a/apps/web/src/components/chat/ProviderStatusBanner.browser.tsx b/apps/web/src/components/chat/providerStatusNotice.browser.tsx similarity index 67% rename from apps/web/src/components/chat/ProviderStatusBanner.browser.tsx rename to apps/web/src/components/chat/providerStatusNotice.browser.tsx index 4577f9459..095739cbf 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.browser.tsx +++ b/apps/web/src/components/chat/providerStatusNotice.browser.tsx @@ -29,7 +29,21 @@ vi.mock("../../localApi", () => ({ })), })); -import { ProviderStatusBanner } from "./ProviderStatusBanner"; +import { ComposerNoticeDock } from "./ComposerNoticeDock"; +import { useProviderStatusNotice } from "./providerStatusNotice"; + +function ProviderStatusNoticeHarness({ + status, + activeTurnInProgress = false, + suppressed = false, +}: { + status: ServerProvider | null; + activeTurnInProgress?: boolean; + suppressed?: boolean; +}) { + const notice = useProviderStatusNotice({ activeTurnInProgress, status, suppressed }); + return ; +} function renderWithTestRouter(children: ReactNode) { const rootRoute = createRootRoute({ @@ -66,22 +80,31 @@ function makeProvider(overrides: Partial = {}): ServerProvider { }; } -describe("ProviderStatusBanner", () => { +describe("provider status composer notice", () => { afterEach(() => { refreshProvidersMock.mockClear(); document.body.innerHTML = ""; }); - it("offers compact targeted refresh and diagnostics actions for provider probe timeouts", async () => { + it("stays hidden while a held-send notice names the same problem", async () => { + // The held-send row carries the actions that fix the provider; without + // suppression this error-severity row would outrank it in the dock. + const provider = makeProvider({ status: "error", message: "Codex is unavailable." }); + await renderWithTestRouter(); + + await expect.element(page.getByText("Codex provider status")).not.toBeInTheDocument(); + }); + + it("offers targeted refresh and diagnostics actions for provider probe timeouts", async () => { const provider = makeProvider({ statusReason: "provider_probe_timeout", message: "Codex status check timed out after 60 seconds. Existing sessions may still work; refresh provider status if this keeps happening.", }); - const screen = await renderWithTestRouter(); + const screen = await renderWithTestRouter(); try { - await expect.element(page.getByText("Codex provider status:", { exact: true })).toBeVisible(); + await expect.element(page.getByText("Codex provider status", { exact: true })).toBeVisible(); await expect .element(page.getByText("Codex status check timed out after 60 seconds.")) .toBeVisible(); @@ -99,10 +122,9 @@ describe("ProviderStatusBanner", () => { } }); - it("does not float provider probe warnings over an active turn", async () => { - const provider = makeProvider(); + it("does not show provider probe warnings over an active turn", async () => { const screen = await renderWithTestRouter( - , + , ); try { @@ -121,12 +143,17 @@ describe("ProviderStatusBanner", () => { message: "Codex CLI is not authenticated.", }); const screen = await renderWithTestRouter( - , + , ); try { await expect.element(page.getByText("Codex provider status", { exact: true })).toBeVisible(); await expect.element(page.getByText("Codex CLI is not authenticated.")).toBeVisible(); + expect( + document + .querySelector("[data-composer-notice-severity]") + ?.getAttribute("data-composer-notice-severity"), + ).toBe("error"); } finally { await screen.unmount(); } diff --git a/apps/web/src/components/chat/ProviderStatusBanner.logic.test.ts b/apps/web/src/components/chat/providerStatusNotice.logic.test.ts similarity index 55% rename from apps/web/src/components/chat/ProviderStatusBanner.logic.test.ts rename to apps/web/src/components/chat/providerStatusNotice.logic.test.ts index 661b168b9..7c7513f12 100644 --- a/apps/web/src/components/chat/ProviderStatusBanner.logic.test.ts +++ b/apps/web/src/components/chat/providerStatusNotice.logic.test.ts @@ -6,10 +6,9 @@ import { } from "@threadlines/contracts"; import { - getProviderStatusNoticeKind, PROVIDER_STATUS_SLOW_NOTICE_DELAY_MS, - shouldRenderProviderStatusBanner, -} from "./ProviderStatusBanner"; + shouldShowProviderStatusNotice, +} from "./providerStatusNotice"; const CHECKED_AT_MS = Date.UTC(2026, 5, 1, 12, 0, 0); const CHECKED_AT_ISO = new Date(CHECKED_AT_MS).toISOString(); @@ -33,16 +32,16 @@ function makeProvider(overrides: Partial = {}): ServerProvider { }; } -describe("shouldRenderProviderStatusBanner", () => { - it("does not render for absent, ready, or disabled provider snapshots", () => { - expect(shouldRenderProviderStatusBanner(null)).toBe(false); - expect(shouldRenderProviderStatusBanner(makeProvider({ status: "ready" }))).toBe(false); - expect(shouldRenderProviderStatusBanner(makeProvider({ status: "disabled" }))).toBe(false); +describe("shouldShowProviderStatusNotice", () => { + it("does not show for absent, ready, or disabled provider snapshots", () => { + expect(shouldShowProviderStatusNotice(null)).toBe(false); + expect(shouldShowProviderStatusNotice(makeProvider({ status: "ready" }))).toBe(false); + expect(shouldShowProviderStatusNotice(makeProvider({ status: "disabled" }))).toBe(false); }); it("suppresses warning-level provider probes while a turn is active", () => { expect( - shouldRenderProviderStatusBanner(makeProvider({ status: "warning" }), { + shouldShowProviderStatusNotice(makeProvider({ status: "warning" }), { activeTurnInProgress: true, }), ).toBe(false); @@ -50,7 +49,7 @@ describe("shouldRenderProviderStatusBanner", () => { it("hides pending Codex probe status before the slow notice delay", () => { expect( - shouldRenderProviderStatusBanner( + shouldShowProviderStatusNotice( makeProvider({ statusReason: "provider_probe_pending", }), @@ -62,25 +61,21 @@ describe("shouldRenderProviderStatusBanner", () => { }); it("shows pending Codex probe status after the slow notice delay", () => { - const provider = makeProvider({ - statusReason: "provider_probe_pending", - }); - expect( - shouldRenderProviderStatusBanner(provider, { - nowMs: CHECKED_AT_MS + PROVIDER_STATUS_SLOW_NOTICE_DELAY_MS, - }), + shouldShowProviderStatusNotice( + makeProvider({ + statusReason: "provider_probe_pending", + }), + { + nowMs: CHECKED_AT_MS + PROVIDER_STATUS_SLOW_NOTICE_DELAY_MS, + }, + ), ).toBe(true); - expect( - getProviderStatusNoticeKind(provider, { - nowMs: CHECKED_AT_MS + PROVIDER_STATUS_SLOW_NOTICE_DELAY_MS, - }), - ).toBe("compact"); }); - it("uses compact treatment for Codex probe timeouts", () => { + it("shows Codex probe timeouts immediately", () => { expect( - getProviderStatusNoticeKind( + shouldShowProviderStatusNotice( makeProvider({ statusReason: "provider_probe_timeout", }), @@ -88,20 +83,20 @@ describe("shouldRenderProviderStatusBanner", () => { nowMs: CHECKED_AT_MS, }, ), - ).toBe("compact"); + ).toBe(true); }); - it("still renders warning-level provider probes while idle", () => { + it("still shows warning-level provider probes while idle", () => { expect( - shouldRenderProviderStatusBanner(makeProvider({ status: "warning" }), { + shouldShowProviderStatusNotice(makeProvider({ status: "warning" }), { activeTurnInProgress: false, }), ).toBe(true); }); - it("still renders provider errors while a turn is active", () => { + it("still shows provider errors while a turn is active", () => { expect( - shouldRenderProviderStatusBanner(makeProvider({ status: "error" }), { + shouldShowProviderStatusNotice(makeProvider({ status: "error" }), { activeTurnInProgress: true, }), ).toBe(true); diff --git a/apps/web/src/components/chat/providerStatusNotice.tsx b/apps/web/src/components/chat/providerStatusNotice.tsx new file mode 100644 index 000000000..874f887db --- /dev/null +++ b/apps/web/src/components/chat/providerStatusNotice.tsx @@ -0,0 +1,146 @@ +/** + * The composer notice for an unhealthy provider snapshot. + * + * @module providerStatusNotice + */ +import { ProviderDriverKind, type ServerProvider } from "@threadlines/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { formatProviderDriverKindLabel } from "../../providerModels"; +import type { ComposerNotice } from "./composerNotices"; +import { StatusNoticeActionButtons, useProviderStatusRefresh } from "./statusNotice"; + +const CODEX_DRIVER_KIND = ProviderDriverKind.make("codex"); +export const PROVIDER_STATUS_SLOW_NOTICE_DELAY_MS = 25_000; + +function isCodexProviderProbeStatus(status: ServerProvider): boolean { + return ( + status.driver === CODEX_DRIVER_KIND && + (status.statusReason === "provider_probe_pending" || + status.statusReason === "provider_probe_timeout") + ); +} + +function providerStatusAgeMs(status: ServerProvider, nowMs: number): number { + const checkedAtMs = Date.parse(status.checkedAt); + if (!Number.isFinite(checkedAtMs)) { + return PROVIDER_STATUS_SLOW_NOTICE_DELAY_MS; + } + return Math.max(0, nowMs - checkedAtMs); +} + +export function getPendingProbeNoticeDelayMs(status: ServerProvider, nowMs: number): number { + if ( + status.status !== "warning" || + status.statusReason !== "provider_probe_pending" || + !isCodexProviderProbeStatus(status) + ) { + return 0; + } + return Math.max(0, PROVIDER_STATUS_SLOW_NOTICE_DELAY_MS - providerStatusAgeMs(status, nowMs)); +} + +export function shouldShowProviderStatusNotice( + status: ServerProvider | null, + options?: { + readonly activeTurnInProgress?: boolean; + readonly nowMs?: number; + }, +): boolean { + if (!status || status.status === "ready" || status.status === "disabled") { + return false; + } + if (status.status === "error") { + return true; + } + if (options?.activeTurnInProgress === true) { + return false; + } + if ( + isCodexProviderProbeStatus(status) && + status.statusReason === "provider_probe_pending" && + getPendingProbeNoticeDelayMs(status, options?.nowMs ?? Date.now()) > 0 + ) { + return false; + } + return true; +} + +/** + * Builds the provider-status notice, including the delayed reveal for a Codex + * probe that is merely slow: a probe that has not answered yet is usually + * about to, so it only earns the row once it has been pending long enough to + * be worth mentioning. + */ +export function useProviderStatusNotice(input: { + readonly status: ServerProvider | null; + readonly activeTurnInProgress: boolean; + /** + * True while a held-send notice is up for this same instance. That notice + * states the identical fact with the actions that resolve it, and severity + * ranking would otherwise put this vaguer row in front of it. + */ + readonly suppressed?: boolean; +}): ComposerNotice | null { + const { activeTurnInProgress, status, suppressed = false } = input; + const [nowMs, setNowMs] = useState(() => Date.now()); + const { isRefreshing, refreshError, refreshProvider } = useProviderStatusRefresh( + status?.instanceId ?? null, + ); + + const checkedAt = status?.checkedAt; + const instanceId = status?.instanceId; + const statusReason = status?.statusReason; + useEffect(() => { + setNowMs(Date.now()); + }, [checkedAt, instanceId, statusReason]); + + useEffect(() => { + if (!status || activeTurnInProgress) { + return; + } + const remainingMs = getPendingProbeNoticeDelayMs(status, nowMs); + if (remainingMs <= 0) { + return; + } + const timeoutId = window.setTimeout(() => { + setNowMs(Date.now()); + }, remainingMs + 50); + return () => { + window.clearTimeout(timeoutId); + }; + }, [activeTurnInProgress, nowMs, status]); + + const visible = + !suppressed && shouldShowProviderStatusNotice(status, { activeTurnInProgress, nowMs }); + + return useMemo(() => { + if (!status || !visible) { + return null; + } + const providerLabel = + status.displayName?.trim() || formatProviderDriverKindLabel(status.driver); + const defaultMessage = + status.status === "error" + ? `${providerLabel} provider is unavailable.` + : `${providerLabel} provider has limited availability.`; + const message = + status.statusReason === "provider_probe_pending" + ? `${providerLabel} status check is taking longer than usual. Existing sessions may still work.` + : (status.message ?? defaultMessage); + + return { + id: `provider-status:${status.instanceId}`, + severity: status.status === "error" ? "error" : "warning", + lead: `${providerLabel} provider status`, + detail: refreshError ? `${message} ${refreshError}` : message, + actions: ( + + ), + } satisfies ComposerNotice; + }, [isRefreshing, refreshError, refreshProvider, status, visible]); +} diff --git a/apps/web/src/components/chat/SessionStartupNotice.browser.tsx b/apps/web/src/components/chat/sessionStartupNotice.browser.tsx similarity index 72% rename from apps/web/src/components/chat/SessionStartupNotice.browser.tsx rename to apps/web/src/components/chat/sessionStartupNotice.browser.tsx index 2f958a4f5..d82563122 100644 --- a/apps/web/src/components/chat/SessionStartupNotice.browser.tsx +++ b/apps/web/src/components/chat/sessionStartupNotice.browser.tsx @@ -29,7 +29,29 @@ vi.mock("../../localApi", () => ({ })), })); -import { SESSION_STARTUP_SLOW_NOTICE_DELAY_MS, SessionStartupNotice } from "./SessionStartupNotice"; +import { ComposerNoticeDock } from "./ComposerNoticeDock"; +import { + SESSION_STARTUP_SLOW_NOTICE_DELAY_MS, + useSessionStartupNotice, +} from "./sessionStartupNotice"; + +function SessionStartupNoticeHarness({ + startedAt, + providerStatus, + suppressed = false, +}: { + startedAt: string | null; + providerStatus: ServerProvider | null; + suppressed?: boolean; +}) { + const notice = useSessionStartupNotice({ + isSessionStarting: true, + providerStatus, + startedAt, + suppressed, + }); + return ; +} function renderWithTestRouter(children: ReactNode) { const rootRoute = createRootRoute({ @@ -69,7 +91,7 @@ function slowStartedAt(): string { return new Date(Date.now() - SESSION_STARTUP_SLOW_NOTICE_DELAY_MS - 1_000).toISOString(); } -describe("SessionStartupNotice", () => { +describe("session startup composer notice", () => { afterEach(() => { refreshProvidersMock.mockClear(); document.body.innerHTML = ""; @@ -78,15 +100,11 @@ describe("SessionStartupNotice", () => { it("offers targeted refresh and diagnostics actions once startup runs long", async () => { const provider = makeProvider(); const screen = await renderWithTestRouter( - , + , ); try { - await expect.element(page.getByText("Turn startup:", { exact: true })).toBeVisible(); + await expect.element(page.getByText("Turn startup", { exact: true })).toBeVisible(); await expect .element(page.getByText("Preparing this turn is taking longer than usual.")) .toBeVisible(); @@ -106,27 +124,23 @@ describe("SessionStartupNotice", () => { it("stays hidden before the slow-startup threshold", async () => { const screen = await renderWithTestRouter( - , ); try { - await expect - .element(page.getByText("Turn startup:", { exact: true })) - .not.toBeInTheDocument(); + await expect.element(page.getByText("Turn startup", { exact: true })).not.toBeInTheDocument(); expect(refreshProvidersMock).not.toHaveBeenCalled(); } finally { await screen.unmount(); } }); - it("stays hidden while another status banner is already visible", async () => { + it("stays hidden while a more specific notice already explains the stall", async () => { const screen = await renderWithTestRouter( - { ); try { - await expect - .element(page.getByText("Turn startup:", { exact: true })) - .not.toBeInTheDocument(); + await expect.element(page.getByText("Turn startup", { exact: true })).not.toBeInTheDocument(); } finally { await screen.unmount(); } @@ -144,11 +156,11 @@ describe("SessionStartupNotice", () => { it("omits the refresh action without a provider snapshot", async () => { const screen = await renderWithTestRouter( - , + , ); try { - await expect.element(page.getByText("Turn startup:", { exact: true })).toBeVisible(); + await expect.element(page.getByText("Turn startup", { exact: true })).toBeVisible(); await expect .element(page.getByRole("button", { name: "Refresh provider status" })) .not.toBeInTheDocument(); diff --git a/apps/web/src/components/chat/SessionStartupNotice.logic.test.ts b/apps/web/src/components/chat/sessionStartupNotice.logic.test.ts similarity index 98% rename from apps/web/src/components/chat/SessionStartupNotice.logic.test.ts rename to apps/web/src/components/chat/sessionStartupNotice.logic.test.ts index b192c2560..5e61fff3d 100644 --- a/apps/web/src/components/chat/SessionStartupNotice.logic.test.ts +++ b/apps/web/src/components/chat/sessionStartupNotice.logic.test.ts @@ -4,7 +4,7 @@ import { getSessionStartupNoticeDelayMs, SESSION_STARTUP_SLOW_NOTICE_DELAY_MS, shouldShowSessionStartupNotice, -} from "./SessionStartupNotice"; +} from "./sessionStartupNotice"; const STARTED_AT_MS = Date.UTC(2026, 5, 1, 12, 0, 0); const STARTED_AT_ISO = new Date(STARTED_AT_MS).toISOString(); diff --git a/apps/web/src/components/chat/SessionStartupNotice.tsx b/apps/web/src/components/chat/sessionStartupNotice.tsx similarity index 56% rename from apps/web/src/components/chat/SessionStartupNotice.tsx rename to apps/web/src/components/chat/sessionStartupNotice.tsx index bdb317b6b..bf3e925c3 100644 --- a/apps/web/src/components/chat/SessionStartupNotice.tsx +++ b/apps/web/src/components/chat/sessionStartupNotice.tsx @@ -1,10 +1,13 @@ +/** + * The composer notice for a turn whose startup is running long. + * + * @module sessionStartupNotice + */ import type { ServerProvider } from "@threadlines/contracts"; -import { memo, useEffect, useState } from "react"; -import { - CompactStatusNoticeRow, - StatusNoticeActionButtons, - useProviderStatusRefresh, -} from "./statusNotice"; +import { useEffect, useMemo, useState } from "react"; + +import type { ComposerNotice } from "./composerNotices"; +import { StatusNoticeActionButtons, useProviderStatusRefresh } from "./statusNotice"; export const SESSION_STARTUP_SLOW_NOTICE_DELAY_MS = 30_000; @@ -35,17 +38,19 @@ export function shouldShowSessionStartupNotice(input: { return getSessionStartupNoticeDelayMs(input) === 0; } -export const SessionStartupNotice = memo(function SessionStartupNotice({ - isSessionStarting, - startedAt, - suppressed = false, - providerStatus, -}: { - isSessionStarting: boolean; - startedAt: string | null; - suppressed?: boolean; - providerStatus: ServerProvider | null; -}) { +/** + * `suppressed` stays even though the dock could rank this notice below the + * others: a provider-status or turn-error row already names the reason startup + * is stuck, so adding "1 more" for a vaguer restatement of it only makes the + * user open the stack to learn nothing. + */ +export function useSessionStartupNotice(input: { + readonly isSessionStarting: boolean; + readonly startedAt: string | null; + readonly suppressed: boolean; + readonly providerStatus: ServerProvider | null; +}): ComposerNotice | null { + const { isSessionStarting, providerStatus, startedAt, suppressed } = input; const [nowMs, setNowMs] = useState(() => Date.now()); const { isRefreshing, refreshError, refreshProvider } = useProviderStatusRefresh( providerStatus?.instanceId ?? null, @@ -71,22 +76,27 @@ export const SessionStartupNotice = memo(function SessionStartupNotice({ }; }, [isSessionStarting, nowMs, startedAt, suppressed]); - if (suppressed || !shouldShowSessionStartupNotice({ isSessionStarting, nowMs, startedAt })) { - return null; - } + const visible = + !suppressed && shouldShowSessionStartupNotice({ isSessionStarting, nowMs, startedAt }); - return ( - { + if (!visible) { + return null; + } + return { + id: "session-startup", + severity: "warning", + lead: "Turn startup", + detail: refreshError + ? `${SESSION_STARTUP_SLOW_MESSAGE} ${refreshError}` + : SESSION_STARTUP_SLOW_MESSAGE, + actions: ( - } - /> - ); -}); + ), + } satisfies ComposerNotice; + }, [isRefreshing, providerStatus, refreshError, refreshProvider, visible]); +} diff --git a/apps/web/src/components/chat/statusNotice.tsx b/apps/web/src/components/chat/statusNotice.tsx index 5d49b15ea..43c053904 100644 --- a/apps/web/src/components/chat/statusNotice.tsx +++ b/apps/web/src/components/chat/statusNotice.tsx @@ -1,9 +1,8 @@ import type { ProviderInstanceId } from "@threadlines/contracts"; import { Link } from "@tanstack/react-router"; -import { useCallback, useState, type ReactNode } from "react"; -import { ActivityIcon, CircleAlertIcon, LoaderIcon, RefreshCwIcon } from "lucide-react"; +import { useCallback, useState } from "react"; +import { ActivityIcon, LoaderIcon, RefreshCwIcon } from "lucide-react"; import { ensureLocalApi } from "../../localApi"; -import { cn } from "../../lib/utils"; import { Button } from "../ui/button"; export function useProviderStatusRefresh(instanceId: ProviderInstanceId | null): { @@ -35,11 +34,11 @@ export function useProviderStatusRefresh(instanceId: ProviderInstanceId | null): } export function StatusNoticeActionButtons({ - variant, + variant = "ghost", isRefreshing, onRefresh, }: { - variant: "ghost" | "outline"; + variant?: "ghost" | "outline"; isRefreshing: boolean; onRefresh: (() => void) | null; }) { @@ -76,57 +75,3 @@ export function StatusNoticeActionButtons({ ); } - -export function CompactStatusNoticeRow({ - title, - message, - errorText, - actions, - tone = "warning", -}: { - title: string; - message: ReactNode; - errorText?: ReactNode; - actions: ReactNode; - tone?: "warning" | "error"; -}) { - return ( -
-
-
-
- ); -} diff --git a/apps/web/src/components/chat/threadErrorNotice.test.tsx b/apps/web/src/components/chat/threadErrorNotice.test.tsx new file mode 100644 index 000000000..9281eab0f --- /dev/null +++ b/apps/web/src/components/chat/threadErrorNotice.test.tsx @@ -0,0 +1,96 @@ +import { ProviderDriverKind } from "@threadlines/contracts"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vite-plus/test"; + +import type { ComposerNotice } from "./composerNotices"; +import { ComposerNoticeDock } from "./ComposerNoticeDock"; +import { buildThreadErrorNotice } from "./threadErrorNotice"; + +function renderNotice(notice: ComposerNotice | null): string { + return renderToStaticMarkup(); +} + +describe("buildThreadErrorNotice", () => { + it("produces nothing without an error", () => { + expect(buildThreadErrorNotice({ error: null })).toBe(null); + }); + + it("renders provider auth recovery steps and terminal action", () => { + const markup = renderNotice( + buildThreadErrorNotice({ + error: "Failed to authenticate. API Error: 401 Invalid authentication credentials", + providerLabel: "Claude", + authReconnect: { + provider: ProviderDriverKind.make("claudeAgent"), + command: "claude auth login", + message: "Failed to authenticate. API Error: 401 Invalid authentication credentials", + }, + onRunAuthReconnect: () => {}, + }), + ); + + expect(markup).toContain("Claude needs sign-in."); + expect(markup).toContain("claude auth login"); + expect(markup).toContain("complete the browser sign-in, then retry"); + expect(markup).toContain("Last error: Failed to authenticate."); + expect(markup).toContain(">Sign in<"); + expect(markup).toContain('data-composer-notice-severity="error"'); + expect(markup).toContain('role="alert"'); + }); + + it("renders a Codex usage reset action for usage-limit errors", () => { + const markup = renderNotice( + buildThreadErrorNotice({ + error: "You've hit your usage limit.", + usageReset: { + availableCount: 2, + onReset: () => {}, + }, + }), + ); + + expect(markup).toContain("Turn failed."); + expect(markup).toContain("usage limit."); + expect(markup).toContain("Reset usage"); + expect(markup).toContain("Reset Codex usage"); + }); + + it("renders a retry action for retryable turn failures", () => { + const markup = renderNotice( + buildThreadErrorNotice({ + error: "API Error: Unable to connect to API (ECONNRESET)", + retry: { + isRetrying: false, + onRetry: () => {}, + }, + }), + ); + + expect(markup).toContain("ECONNRESET"); + expect(markup).toContain(">Retry<"); + expect(markup).toContain("Retry last message"); + }); + + it("disables the retry action while a retry is dispatching", () => { + const markup = renderNotice( + buildThreadErrorNotice({ + error: "API Error: Unable to connect to API (ECONNRESET)", + retry: { + isRetrying: true, + onRetry: () => {}, + }, + }), + ); + + expect(markup).toContain("Retrying"); + expect(markup).toContain("disabled"); + }); + + it("omits the retry action when no retry handler is provided", () => { + const markup = renderNotice( + buildThreadErrorNotice({ error: "API Error: some validation problem" }), + ); + + expect(markup).not.toContain("Retry last message"); + }); +}); diff --git a/apps/web/src/components/chat/threadErrorNotice.tsx b/apps/web/src/components/chat/threadErrorNotice.tsx new file mode 100644 index 000000000..f1e8095bd --- /dev/null +++ b/apps/web/src/components/chat/threadErrorNotice.tsx @@ -0,0 +1,93 @@ +/** + * The composer notice for a turn that already failed. + * + * @module threadErrorNotice + */ +import { RefreshCwIcon, RotateCcwIcon } from "lucide-react"; + +import type { ProviderAuthReconnectAction } from "../../session-logic"; +import { formatProviderRateLimitResetCreditTooltip } from "../ProviderRateLimitResetCredit"; +import { Button } from "../ui/button"; +import type { ComposerNotice } from "./composerNotices"; +import { buildProviderSignInNotice } from "./providerReadinessNotice"; + +interface UsageResetAction { + readonly availableCount: number; + readonly isResetting?: boolean; + readonly onReset: () => void; +} + +interface TurnRetryAction { + readonly isRetrying: boolean; + readonly onRetry: () => void; +} + +export function buildThreadErrorNotice({ + error, + authReconnect, + usageReset, + retry, + providerLabel, + onRunAuthReconnect, + onDismiss, +}: { + error: string | null; + authReconnect?: ProviderAuthReconnectAction | null; + usageReset?: UsageResetAction | null; + retry?: TurnRetryAction | null; + providerLabel?: string; + onRunAuthReconnect?: (action: ProviderAuthReconnectAction) => void; + onDismiss?: () => void; +}): ComposerNotice | null { + if (!error) { + return null; + } + + if (authReconnect) { + return buildProviderSignInNotice({ + id: "thread-error-auth", + providerLabel: providerLabel?.trim() || "Provider", + command: authReconnect.command, + detailSuffix: `Last error: ${error}`, + onRunSignIn: onRunAuthReconnect ? () => onRunAuthReconnect(authReconnect) : undefined, + ...(onDismiss ? { onDismiss } : {}), + }); + } + + return { + id: "thread-error", + severity: "error", + lead: "Turn failed.", + detail: error, + actions: ( + <> + {retry ? ( + + ) : null} + {usageReset ? ( + + ) : null} + + ), + dismissLabel: "Dismiss error", + ...(onDismiss ? { onDismiss } : {}), + }; +} diff --git a/docs/design/onboarding-setup-card.html b/docs/design/onboarding-setup-card.html new file mode 100644 index 000000000..e8088d44f --- /dev/null +++ b/docs/design/onboarding-setup-card.html @@ -0,0 +1,646 @@ + + + + + + Onboarding mockup — setup card, composer notices, mobile pairing + + + +
+

Onboarding mockup

+

+ Three pieces: the first-run setup card, the replacement for the stacked red notice boxes, + and the phone version of the never-paired screen. Dark theme. Everything is flat and + hairline-divided per the design system; the only color is status dots and the one primary + action per surface. +

+ + +

1 · First run — the setup card

+

+ Lives where the thread content normally goes, on first launch only. It is the same provider + status data Settings shows, but with the fix actions right on the rows. It never comes back + once things are green (and "Skip for now" hides it for people who know what they're doing). +

+ +
+ +
+
Welcome
+
+
+

Set up Threadlines

+

+ Connect a coding agent and pick a folder. Rows update live as you go. +

+
+
+ + Codex + v0.146.1 + Not signed in. Uses your ChatGPT account. + +
+
+ + Claude + Not installed. Install Claude Code, then sign in with your Claude + account. + +
+
+ + Project + B-git-project · the folder you launched from + +
+
+ +
+
+
+
+

+ Behavior: "Sign in" opens the in-app terminal running the login command right below + the row, and the dot flips amber → green live when it completes ("Start first thread" + enables at the same moment). "Install guide" goes to the provider page in Settings. One + provider green is enough; the card doesn't nag about the second. No box around the card — + it's typography and dividers on the empty canvas, matching the rest of the app. +

+ + +

2 · Composer notices — replacing the stacked red boxes

+

+ Today, provider status and errors float as translucent red boxes at the top of the chat, and + they stack. Instead: one slim notice row docked to the top of the composer — it shares the + composer's edges, so it reads as "about sending", not as chat content. A dot for severity, + one line of text, actions inline. Never more than one row; extra notices collapse behind a + count. +

+ +
+
+
Held message — provider signed out
+
+
+ + Codex needs sign-in. Run codex login, then your message + sends. + + + +
+
+
hello
+
+ gpt-5.6-solBuildFull access +
+
+
+

+ "I've signed in" replaces "Send anyway": it re-checks the provider and sends the + held message automatically if the check passes; if not, the row shakes its text to + "Still signed out — the terminal shows where the sign-in stopped." The typed message + never leaves the composer either way. +

+
+
+
Turn error — signed-in user, same surface
+
+
+ + Turn failed. Codex hit its usage limit · resets 6:00 PM. + + 1 more ▾ + +
+
+
Message Codex…
+
+ gpt-5.6-solBuildFull access +
+
+
+

+ Stacking: when a second notice exists (say, "Codex update available"), it doesn't + render a second row — "1 more ▾" expands the list in place, worst severity always on + top. Info-level notices (updates, version skew) use a neutral dot and never show + unprompted while a red or amber notice is up. +

+
+
+

+ Does the setup card make these redundant? No — the card fixes the cold start, but + signed-in users still hit sign-outs, rate limits, and turn errors mid-life. This one notice + surface handles all of it, so the floating red containers go away everywhere, not just for + new users. +

+ + +

3 · Phone — never paired

+

+ A phone can't install the desktop app on itself, so no download button. The phone's job is + to receive the pairing, so the copy is the three real steps, and the QR does the work. + Desktop browsers keep the download button they got in the last batch. +

+ +
+
+
Threadlines
+

Pair with your computer

+

Threadlines runs on your computer; this phone connects to it.

+
+
+ 1On your computer, install Threadlines from + threadlines.dev/download +
+
+ 2In the desktop app, open Settings → Devices → Add device +
+
+ 3Scan the QR code it shows with this phone's camera +
+
+
+ +
+
+
+

+ Notes: scanning the QR opens this page with the token filled in, so most people never + touch the button. "I have a setup link" opens the existing paste form for people whose + desktop is in another room. +

+
+ +