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
65 changes: 47 additions & 18 deletions apps/desktop/src/components/ChatSurface.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { memo, useMemo } from "react";
import { memo, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import {
messageHasTranscriptContent,
vendorAccountOmitsSessionModel,
} from "../lib/chat-launch-error";
import { Composer } from "./Composer";
import { HomeMascotLogo } from "./HomeMascotLogo";
import { HomeProjectSwitcher } from "./HomeProjectSwitcher";
Expand Down Expand Up @@ -53,6 +57,10 @@ export const ChatSurface = memo(function ChatSurface() {
const error = useAppStore((state) => state.error);
const errorCode = useAppStore((state) => state.errorCode);
const errorRetriable = useAppStore((state) => state.errorRetriable);
const [hiddenVendorModelKey, setHiddenVendorModelKey] = useState<string | null>(
null,
);
const providers = useAppStore((state) => state.providers);
const activeSession = useAppStore((state) =>
state.activeSessionId
? state.sessions.find((session) => session.id === state.activeSessionId)
Expand Down Expand Up @@ -107,19 +115,28 @@ export const ChatSurface = memo(function ChatSurface() {
const hasTranscript =
Boolean(activePermission) ||
askPending ||
messages.some((message) => {
const hasContent = Boolean((message.content || "").trim());
const hasThinking =
typeof message.thinking === "string" &&
Boolean(message.thinking.trim());
if (message.role === "assistant") return hasContent || hasThinking;
return hasContent || message.role === "tool";
});
messages.some((message) => messageHasTranscriptContent(message));
// The empty state belongs to the session on screen. While a cold switch is
// still resolving, the visible pane keeps its own transcript, so the hero must
// not take over just because the destination projection is still empty.
const showEmptyState =
!hasTranscript && (!visibleSessionId || visibleSessionId === activeSessionId);
const vendorModelMissing = vendorAccountOmitsSessionModel(
activeSession,
providers,
);
const vendorModelKey = vendorModelMissing
? `${activeSession?.providerId ?? ""}:${activeSession?.modelId ?? ""}`
: null;
const showVendorModelError =
vendorModelKey !== null && hiddenVendorModelKey !== vendorModelKey;
const noticeError =
error ?? (showVendorModelError ? t("errors.MODEL_NOT_CONFIGURED") : null);
const noticeCode = error
? errorCode
: showVendorModelError
? "MODEL_NOT_CONFIGURED"
: null;
return (
<div
className={`chat-surface route-surface${sessionSwitching ? " session-switching" : ""}`}
Expand Down Expand Up @@ -185,17 +202,23 @@ export const ChatSurface = memo(function ChatSurface() {
</>
)}

{error ? (
{noticeError ? (
<div className="chat-error-layer">
<div className="chat-error-notice">
<span title={error ?? undefined}>
{errorCode && i18nHasError(t, errorCode)
? t(`errors.${errorCode}`)
: error}
<span title={noticeError}>
{noticeCode && i18nHasError(t, noticeCode)
? t(`errors.${noticeCode}`)
: noticeError}
{showVendorModelError && !error && activeSession?.modelId ? (
<>
{" "}
<code>{activeSession.modelId}</code>
</>
) : null}
</span>
{(errorCode === "MODEL_NOT_CONFIGURED" ||
errorCode === "PROVIDER_SECRET_MISSING" ||
errorCode === "PROVIDER_UNAUTHORIZED") && (
{(noticeCode === "MODEL_NOT_CONFIGURED" ||
noticeCode === "PROVIDER_SECRET_MISSING" ||
noticeCode === "PROVIDER_UNAUTHORIZED") && (
<button
type="button"
className="chat-error-action"
Expand Down Expand Up @@ -224,7 +247,13 @@ export const ChatSurface = memo(function ChatSurface() {
tooltip={t("errors.action.dismiss")}
ariaLabel={t("errors.action.dismiss")}
className="chat-error-dismiss"
onClick={() => useAppStore.getState().clearError()}
onClick={() => {
if (error) {
useAppStore.getState().clearError();
return;
}
if (vendorModelKey) setHiddenVendorModelKey(vendorModelKey);
}}
>
<IconX size={13} />
</TooltipButton>
Expand Down
49 changes: 49 additions & 0 deletions apps/desktop/src/lib/chat-launch-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { modelIdsMatch, OAUTH_AUTH_KIND } from "@pi-desktop/shared";

type TranscriptMessage = {
role: string;
content?: string;
thinking?: string;
error?: unknown;
};

/** An assistant row that only carries a launch error is still the conversation. */
export function messageHasTranscriptContent(message: TranscriptMessage): boolean {
const hasContent = Boolean((message.content || "").trim());
const hasThinking =
typeof message.thinking === "string" && Boolean(message.thinking.trim());
if (message.role === "assistant") {
return hasContent || hasThinking || Boolean(message.error);
}
return hasContent || message.role === "tool";
}

type VendorSessionModel = {
providerId?: string | null;
modelId?: string | null;
};

type VendorModelProvider = {
id: string;
authKind?: string;
models?: ReadonlyArray<{ id: string }>;
};

/**
* Vendor accounts refuse a session model the account list did not return.
* An empty list means the catalog has not loaded, not that the account
* offers nothing, so it must not turn a normal empty chat into an error.
*/
export function vendorAccountOmitsSessionModel(
session: VendorSessionModel | null | undefined,
providers: readonly VendorModelProvider[],
): boolean {
const providerId = session?.providerId?.trim();
const modelId = session?.modelId?.trim();
if (!providerId || !modelId) return false;
const provider = providers.find((item) => item.id === providerId);
if (!provider || provider.authKind !== OAUTH_AUTH_KIND) return false;
const models = provider.models ?? [];
if (models.length === 0) return false;
return !models.some((model) => modelIdsMatch(model.id, modelId));
}
59 changes: 59 additions & 0 deletions apps/desktop/test/chat-launch-error.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
messageHasTranscriptContent,
vendorAccountOmitsSessionModel,
} from "../src/lib/chat-launch-error.ts";

const oauth = (models) => [{ id: "chatgpt", authKind: "oauth", models }];

test("a launch error with no assistant text still counts as a transcript", () => {
assert.equal(
messageHasTranscriptContent({
role: "assistant",
content: "",
error: { code: "MODEL_NOT_CONFIGURED", message: "missing" },
}),
true,
);
assert.equal(
messageHasTranscriptContent({ role: "assistant", content: " " }),
false,
);
assert.equal(
messageHasTranscriptContent({ role: "user", content: "hello" }),
true,
);
});

test("an empty vendor chat names a model the account did not return", () => {
const session = { providerId: "chatgpt", modelId: "gpt-6-luna" };
assert.equal(
vendorAccountOmitsSessionModel(session, oauth([{ id: "gpt-5.6-luna" }])),
true,
);
assert.equal(
vendorAccountOmitsSessionModel(session, oauth([{ id: "openai/gpt-6-luna" }])),
false,
);
assert.equal(vendorAccountOmitsSessionModel(session, oauth([])), false);
assert.equal(
vendorAccountOmitsSessionModel(session, [
{ id: "chatgpt", authKind: "api_key", models: [{ id: "gpt-5.6-luna" }] },
]),
false,
);
});

test("the chat surface shows that omission instead of an empty page", async () => {
const { readFile } = await import("node:fs/promises");
const surface = await readFile(
new URL("../src/components/ChatSurface.tsx", import.meta.url),
"utf8",
);
assert.match(surface, /messageHasTranscriptContent\(message\)/);
assert.match(surface, /vendorAccountOmitsSessionModel\(/);
assert.match(surface, /activeSession\.modelId/);
assert.match(surface, /noticeError \?/);
});
9 changes: 7 additions & 2 deletions apps/desktop/test/thinking-ui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ const appSource = await readFile(
new URL("../src/components/ChatSurface.tsx", import.meta.url),
"utf8",
);
const launchErrorSource = await readFile(
new URL("../src/lib/chat-launch-error.ts", import.meta.url),
"utf8",
);
const providerCatalogSource = await readMainModule("runtime/provider-catalog.ts");
const sessionIpcSource = await readMainModule("ipc/session-ipc.ts");
const sessionLaunchSource = await readMainModule("runtime/session-launch.ts");
Expand Down Expand Up @@ -285,8 +289,9 @@ test("activity headers omit the redundant status capsule", () => {
});

test("thinking-only assistant streams open the transcript surface", () => {
assert.match(appSource, /typeof message\.thinking === "string"/);
assert.match(appSource, /hasContent \|\| hasThinking/);
assert.match(launchErrorSource, /typeof message\.thinking === "string"/);
assert.match(launchErrorSource, /hasContent \|\| hasThinking/);
assert.match(appSource, /messageHasTranscriptContent\(message\)/);
});

test("provider settings persist model-local limits and thinking configuration", () => {
Expand Down
Loading