From e099cb6e2a94ac0d7e9a1cf76fa9ccd149524e0a Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 30 Jul 2026 11:56:53 +0800 Subject: [PATCH 1/2] fix(vscode): keep sign-in reachable from the no-models screen --- .changeset/vscode-no-models-signin-path.md | 5 ++ apps/vscode/test/app-init.test.ts | 86 +++++++++++++++++++ apps/vscode/tsconfig.json | 2 +- apps/vscode/webview-ui/src/App.tsx | 40 +++++---- .../vscode/webview-ui/src/hooks/useAppInit.ts | 39 +++++++++ apps/vscode/webview-ui/tsconfig.json | 2 +- 6 files changed, 155 insertions(+), 19 deletions(-) create mode 100644 .changeset/vscode-no-models-signin-path.md create mode 100644 apps/vscode/test/app-init.test.ts diff --git a/.changeset/vscode-no-models-signin-path.md b/.changeset/vscode-no-models-signin-path.md new file mode 100644 index 0000000000..4dcb6a4f40 --- /dev/null +++ b/.changeset/vscode-no-models-signin-path.md @@ -0,0 +1,5 @@ +--- +"kimi-code": patch +--- + +Fix new users getting stranded on "Model setup required" with no way back to sign-in when the first login finishes authorization but fails to complete model setup; the screen now offers a path back to the sign-in page so login can be retried. diff --git a/apps/vscode/test/app-init.test.ts b/apps/vscode/test/app-init.test.ts new file mode 100644 index 0000000000..85393df146 --- /dev/null +++ b/apps/vscode/test/app-init.test.ts @@ -0,0 +1,86 @@ +/** + * Scenario: App-level view routing after init, across login state transitions. + * Responsibilities: the sign-in screen must stay reachable from every state — in + * particular the no-models state (a managed OAuth token exists but config.toml + * has no models, e.g. a first login whose model provisioning failed after the + * device flow already persisted the token), where Reload alone can never change + * the on-disk state and the user would otherwise be stranded. + * Wiring: resolveAppView is pure; the bridge and toast boundaries are mocked away. + * Run: pnpm exec vitest run --config apps/vscode/vitest.config.ts test/app-init.test.ts + */ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@/services", () => ({ + bridge: {}, + Events: {}, +})); +vi.mock("@/components/ui/sonner", () => ({ + toast: { error: vi.fn(), warning: vi.fn() }, +})); + +import { resolveAppView, type AppStatus } from "../webview-ui/src/hooks/useAppInit"; + +function resolve( + status: AppStatus, + options: { modelsCount?: number; skippedLogin?: boolean; showLogin?: boolean } = {}, +) { + return resolveAppView({ + status, + modelsCount: options.modelsCount ?? 0, + skippedLogin: options.skippedLogin ?? false, + showLogin: options.showLogin ?? false, + }); +} + +describe("resolveAppView", () => { + it("routes a brand-new user (no token, no models) to the login screen", () => { + expect(resolve("not-logged-in")).toEqual({ view: "login" }); + }); + + it("routes a skipped login without models to no-models with a sign-in path", () => { + expect(resolve("not-logged-in", { skippedLogin: true })).toEqual({ + view: "status", + status: "no-models", + canGoToLogin: true, + }); + }); + + it("routes a skipped login with models to the main view", () => { + expect(resolve("not-logged-in", { skippedLogin: true, modelsCount: 2 })).toEqual({ + view: "main", + }); + }); + + it("keeps a sign-in path in the no-models trap (token without model config)", () => { + // Regression: this state previously rendered "Model setup required" with only + // a Reload button, making the login screen unreachable for affected users. + expect(resolve("no-models")).toEqual({ + view: "status", + status: "no-models", + canGoToLogin: true, + }); + }); + + it("routes to the login screen when the user asks for it from any state", () => { + expect(resolve("no-models", { showLogin: true })).toEqual({ view: "login" }); + expect(resolve("ready", { showLogin: true, modelsCount: 1 })).toEqual({ view: "login" }); + }); + + it("routes a no-models user who skips again back to no-models with a sign-in path", () => { + expect(resolve("no-models", { skippedLogin: true })).toEqual({ + view: "status", + status: "no-models", + canGoToLogin: true, + }); + }); + + it("routes ready to the main view", () => { + expect(resolve("ready", { modelsCount: 1 })).toEqual({ view: "main" }); + }); + + it("routes non-login error statuses to status screens without a sign-in path", () => { + for (const status of ["loading", "no-workspace", "runtime-error"] as const) { + expect(resolve(status)).toEqual({ view: "status", status, canGoToLogin: false }); + } + }); +}); diff --git a/apps/vscode/tsconfig.json b/apps/vscode/tsconfig.json index bc9d284638..2e8486a7dc 100644 --- a/apps/vscode/tsconfig.json +++ b/apps/vscode/tsconfig.json @@ -15,5 +15,5 @@ } }, "include": ["src/**/*", "shared/**/*", "test/**/*"], - "exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts"] + "exclude": ["dist", "node_modules", "webview-ui", "test/settings-store.test.ts", "test/app-init.test.ts"] } diff --git a/apps/vscode/webview-ui/src/App.tsx b/apps/vscode/webview-ui/src/App.tsx index 4a343fe896..2c06bb29a2 100644 --- a/apps/vscode/webview-ui/src/App.tsx +++ b/apps/vscode/webview-ui/src/App.tsx @@ -10,7 +10,7 @@ import { LoginScreen } from "./components/LoginScreen"; import { Toaster, toast } from "./components/ui/sonner"; import { useChatStore, useSettingsStore } from "./stores"; import { bridge, Events } from "./services"; -import { useAppInit } from "./hooks/useAppInit"; +import { useAppInit, resolveAppView } from "./hooks/useAppInit"; import { isPreflightError } from "shared/errors"; import type { UIStreamEvent, StreamError, ExtensionConfig } from "shared/types"; import "./styles/index.css"; @@ -81,48 +81,54 @@ function MainContent({ onAuthAction }: { onAuthAction: () => void }) { export default function App() { const { status, errorMessage, modelsCount, refresh } = useAppInit(); const [skippedLogin, setSkippedLogin] = useState(false); + const [showLogin, setShowLogin] = useState(false); const handleLoginSuccess = useCallback(() => { + setShowLogin(false); + setSkippedLogin(false); refresh(); }, [refresh]); const handleSkip = useCallback(() => { + setShowLogin(false); setSkippedLogin(true); }, []); + const handleShowLogin = useCallback(() => { + setSkippedLogin(false); + setShowLogin(true); + }, []); + const handleAuthAction = useCallback(() => { setSkippedLogin(false); + setShowLogin(false); refresh(); }, [refresh]); - // 未登录且未跳过 - if (status === "not-logged-in" && !skippedLogin) { - return ( -
-
- - -
- ); - } + const resolution = resolveAppView({ status, modelsCount, skippedLogin, showLogin }); - // 跳过登录但没有模型 - if (skippedLogin && modelsCount === 0) { + // 登录界面:未登录且未跳过,或用户从其他界面主动选择登录 + if (resolution.view === "login") { return (
- setSkippedLogin(false)} /> +
); } - // 其他错误状态 - if (status !== "ready" && status !== "not-logged-in") { + // 错误与设置状态界面;no-models 必须保留回到登录界面的入口 + if (resolution.view === "status") { return (
- +
); diff --git a/apps/vscode/webview-ui/src/hooks/useAppInit.ts b/apps/vscode/webview-ui/src/hooks/useAppInit.ts index f23f2d5302..c592b8b7ee 100644 --- a/apps/vscode/webview-ui/src/hooks/useAppInit.ts +++ b/apps/vscode/webview-ui/src/hooks/useAppInit.ts @@ -5,6 +5,45 @@ import type { ExtensionConfig } from "shared/types"; export type AppStatus = "loading" | "no-workspace" | "runtime-error" | "not-logged-in" | "no-models" | "ready"; +export type ConfigErrorStatus = "loading" | "no-workspace" | "runtime-error" | "no-models"; + +export type AppViewResolution = + | { readonly view: "login" } + | { + readonly view: "status"; + readonly status: ConfigErrorStatus; + /** True when the status screen must offer a path to the sign-in screen. */ + readonly canGoToLogin: boolean; + } + | { readonly view: "main" }; + +/** + * Pure view router for App. The `no-models` status (a managed OAuth token + * exists but config.toml has no models — e.g. a first login whose model + * provisioning failed after the device flow already persisted the token) + * must always keep a path back to the sign-in screen: Reload alone cannot + * change the on-disk state, so without it the user is stranded and the + * login UI becomes unreachable. + */ +export function resolveAppView(input: { + readonly status: AppStatus; + readonly modelsCount: number; + readonly skippedLogin: boolean; + readonly showLogin: boolean; +}): AppViewResolution { + const { status, modelsCount, skippedLogin, showLogin } = input; + if (showLogin || (status === "not-logged-in" && !skippedLogin)) { + return { view: "login" }; + } + if (skippedLogin && modelsCount === 0) { + return { view: "status", status: "no-models", canGoToLogin: true }; + } + if (status !== "ready" && status !== "not-logged-in") { + return { view: "status", status, canGoToLogin: status === "no-models" }; + } + return { view: "main" }; +} + export interface AppInitState { status: AppStatus; errorMessage: string | null; diff --git a/apps/vscode/webview-ui/tsconfig.json b/apps/vscode/webview-ui/tsconfig.json index 4e59bb53e0..8ed3015664 100644 --- a/apps/vscode/webview-ui/tsconfig.json +++ b/apps/vscode/webview-ui/tsconfig.json @@ -20,5 +20,5 @@ "shared/*": ["../shared/*"] } }, - "include": ["src", "../test/settings-store.test.ts"] + "include": ["src", "../test/settings-store.test.ts", "../test/app-init.test.ts"] } From d8376a463f5ddf678eb123186737df1fe37e00c7 Mon Sep 17 00:00:00 2001 From: qer Date: Thu, 30 Jul 2026 13:53:55 +0800 Subject: [PATCH 2/2] fix(vscode): stop flagging bundled this.require(...) calls as VSIX runtime dependencies --- apps/vscode/scripts/vsix-verify.mjs | 3 +++ apps/vscode/test/vsix-package.test.ts | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/apps/vscode/scripts/vsix-verify.mjs b/apps/vscode/scripts/vsix-verify.mjs index b1e05219e1..ee81434cc6 100644 --- a/apps/vscode/scripts/vsix-verify.mjs +++ b/apps/vscode/scripts/vsix-verify.mjs @@ -297,6 +297,9 @@ function walkSyntax(value, visit) { function isRuntimeRequire(callee) { if (callee?.type === 'Identifier') return /^(?:__)?require\d*$/.test(callee.name); if (callee?.type !== 'MemberExpression' || callee.computed === true) return false; + // `this.require(...)` is an ordinary class method call (e.g. a private field + // accessor in bundled sources), never a CommonJS require of a bare specifier. + if (callee.object?.type === 'ThisExpression') return false; return callee.property?.type === 'Identifier' && callee.property.name === 'require'; } diff --git a/apps/vscode/test/vsix-package.test.ts b/apps/vscode/test/vsix-package.test.ts index dc1ccb30b2..bd258e7a7b 100644 --- a/apps/vscode/test/vsix-package.test.ts +++ b/apps/vscode/test/vsix-package.test.ts @@ -128,6 +128,27 @@ describe('VSIX verifier CLI (package contract and failure details)', () => { expect(result.stderr).toContain('Bare runtime dependency "left-pad"'); }); + it('does not mistake a bundled this.require(...) method call for a runtime dependency', async () => { + const fixture = await makeVsixFixture('darwin-arm64'); + await writeFile( + join(fixture, 'extension', 'dist', 'extension.js'), + 'class HostEnvironment {\n' + + ' require(field) { return this.info[field]; }\n' + + ' get osKind() { return this.require("osKind"); }\n' + + '}\n' + + 'export function activate() { return new HostEnvironment(); }\n', + ); + + const result = runNode(verifierScript, [ + '--target', + 'darwin-arm64', + '--directory', + fixture, + ]); + + expect(result.status).toBe(0); + }); + it('rejects generated session state inside the package', async () => { const fixture = await makeVsixFixture('win32-arm64'); const stateDir = join(fixture, 'extension', 'runtime', 'profile');