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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/web/src/branding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ??
Expand Down
57 changes: 57 additions & 0 deletions apps/web/src/components/ChatView.browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
75 changes: 75 additions & 0 deletions apps/web/src/components/ChatView.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ProjectId,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
ThreadId,
TurnId,
} from "@threadlines/contracts";
Expand All @@ -28,6 +29,7 @@ import {
deriveDetectedBackgroundRunLabel,
deriveFailedTurnRetryMessageId,
deriveProviderBackgroundRuns,
deriveProviderSendPreflight,
deriveProviderAuthReconnectPrompt,
desktopCapturedScreenshotToFile,
filterUnresolvedProviderBackgroundRuns,
Expand Down Expand Up @@ -2608,3 +2610,76 @@ describe("resolveRemoteBehindCount", () => {
expect(resolveRemoteBehindCount(null)).toBeNull();
});
});

describe("deriveProviderSendPreflight", () => {
const makeProvider = (overrides: Partial<ServerProvider> = {}): 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();
});
});
51 changes: 51 additions & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
type ModelSelection,
type OrchestrationThreadActivity,
type ProviderDriverKind,
type ProviderInstanceId,
type ScopedThreadRef,
type ServerProvider,
type ThreadId,
type TurnId,
} from "@threadlines/contracts";
Expand All @@ -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";
Expand Down Expand Up @@ -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<ServerProvider>;
}): 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;
Expand Down
47 changes: 46 additions & 1 deletion apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -226,6 +227,8 @@ import {
deriveFailedTurnRetryMessageId,
deriveComposerSendState,
deriveProviderAuthReconnectPrompt,
deriveProviderSendPreflight,
type ProviderSendPreflightPrompt,
filterUnresolvedProviderBackgroundRuns,
hasServerAcknowledgedLocalDispatch,
isRetryableThreadError,
Expand Down Expand Up @@ -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<ProviderSendPreflightPrompt | null>(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,
});
Expand Down Expand Up @@ -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 =
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -6195,6 +6225,21 @@ export default function ChatView(props: ChatViewProps) {
onRunAuthReconnect={runProviderAuthReconnect}
onDismiss={() => setThreadError(activeThread.id, null)}
/>
<ProviderSendPreflightNotice
prompt={providerSendPreflight}
onRunSignIn={(prompt) => {
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}
<ForkThreadDialog
state={forkDialogState}
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
applyThreadContentSearchMatches,
buildThreadActionItems,
filterCommandPaletteGroups,
resolveAddProjectUnavailableGuidance,
type CommandPaletteGroup,
} from "./CommandPalette.logic";

Expand Down Expand Up @@ -344,3 +345,22 @@ describe("buildThreadActionItems", () => {
]);
});
});

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,
});
});
});
Loading
Loading