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
5 changes: 5 additions & 0 deletions .changeset/vscode-no-models-signin-path.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions apps/vscode/scripts/vsix-verify.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
86 changes: 86 additions & 0 deletions apps/vscode/test/app-init.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});
});
21 changes: 21 additions & 0 deletions apps/vscode/test/vsix-package.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion apps/vscode/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
40 changes: 23 additions & 17 deletions apps/vscode/webview-ui/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<div className="flex flex-col h-screen text-foreground overflow-hidden">
<Header />
<LoginScreen onLoginSuccess={handleLoginSuccess} onSkip={handleSkip} />
<Toaster position="top-center" />
</div>
);
}
const resolution = resolveAppView({ status, modelsCount, skippedLogin, showLogin });

// 跳过登录但没有模型
if (skippedLogin && modelsCount === 0) {
// 登录界面:未登录且未跳过,或用户从其他界面主动选择登录
if (resolution.view === "login") {
return (
<div className="flex flex-col h-screen text-foreground overflow-hidden">
<Header />
<ConfigErrorScreen type="no-models" errorMessage={errorMessage} onRefresh={refresh} onBackToLogin={() => setSkippedLogin(false)} />
<LoginScreen onLoginSuccess={handleLoginSuccess} onSkip={handleSkip} />
<Toaster position="top-center" />
</div>
);
}

// 其他错误状态
if (status !== "ready" && status !== "not-logged-in") {
// 错误与设置状态界面;no-models 必须保留回到登录界面的入口
if (resolution.view === "status") {
return (
<div className="flex flex-col h-screen text-foreground overflow-hidden">
<Header />
<ConfigErrorScreen type={status} errorMessage={errorMessage} onRefresh={refresh} />
<ConfigErrorScreen
type={resolution.status}
errorMessage={errorMessage}
onRefresh={refresh}
onBackToLogin={resolution.canGoToLogin ? handleShowLogin : undefined}
/>
<Toaster position="top-center" />
</div>
);
Expand Down
39 changes: 39 additions & 0 deletions apps/vscode/webview-ui/src/hooks/useAppInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/vscode/webview-ui/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
}
Loading