fix: improve web login and workspace startup (#853)

This commit is contained in:
qer 2026-06-17 21:39:51 +08:00 committed by GitHub
parent 31f9024046
commit 05fe7595ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 268 additions and 81 deletions

View file

@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---
Fix the web login page and no-workspace conversation startup flow.

View file

@ -61,6 +61,53 @@ const activeWorkspaceSessionCount = computed<number>(
// running: true when activity is not idle
const running = computed(() => client.activity.value !== 'idle');
// Auth readiness gates the main app. Once the first load finishes and auth is
// still missing, show a full-page login entry instead of an in-app banner.
const authReady = computed(() => client.authReady.value);
const showAuthGate = computed(() => client.initialized.value && !authReady.value);
const LOGIN_PATH = '/login';
const authReturnPath = ref<string | null>(null);
const authLogoRef = ref<SVGSVGElement | null>(null);
let authLogoBlinkTimer: ReturnType<typeof setTimeout> | null = null;
function currentPathWithSuffix(): string {
if (typeof window === 'undefined') return '/';
return `${window.location.pathname}${window.location.search}${window.location.hash}`;
}
function replaceBrowserPath(path: string): void {
if (typeof window === 'undefined') return;
window.history.replaceState(window.history.state, '', path);
}
watch(showAuthGate, (show) => {
if (typeof window === 'undefined') return;
if (show) {
if (window.location.pathname !== LOGIN_PATH) {
authReturnPath.value = currentPathWithSuffix();
replaceBrowserPath(LOGIN_PATH);
}
return;
}
if (window.location.pathname === LOGIN_PATH) {
replaceBrowserPath(authReturnPath.value ?? '/');
authReturnPath.value = null;
}
}, { immediate: true });
function blinkAuthLogo(): void {
const el = authLogoRef.value;
if (!el) return;
el.classList.remove('blink-now');
void el.getBoundingClientRect();
el.classList.add('blink-now');
if (authLogoBlinkTimer !== null) clearTimeout(authLogoBlinkTimer);
authLogoBlinkTimer = setTimeout(() => {
authLogoBlinkTimer = null;
el.classList.remove('blink-now');
}, 300);
}
// Dynamic page title: session title first, then workspace name, then app name.
// Prefix an animated spinner when the agent is running so users can see activity
@ -92,6 +139,7 @@ watch(running, (isRunning) => {
const pageTitle = computed<string>(() => {
const prefix = running.value ? `${SPINNER_FRAMES[spinnerFrame.value]} ` : '';
if (showAuthGate.value) return `${prefix}${t('app.authPageTitle')} - Kimi Code Web`;
const sessionTitle = activeSessionTitle.value;
if (sessionTitle) return `${prefix}${sessionTitle} - Kimi Code Web`;
const workspaceName = client.visibleWorkspace.value?.name;
@ -130,6 +178,7 @@ onMounted(() => {
onUnmounted(() => {
document.removeEventListener('keydown', onGlobalKeydown, true);
stopSpinner();
if (authLogoBlinkTimer !== null) clearTimeout(authLogoBlinkTimer);
});
// Escape closes whichever transient right-side detail panel is open.
@ -539,9 +588,6 @@ watch(client.activeSessionId, () => {
// Reference to ConversationPane so we can imperatively switch tabs
const conversationPaneRef = ref<InstanceType<typeof ConversationPane> | null>(null);
// Auth readiness drives onboarding banner
const authReady = computed(() => client.authReady.value);
// Shift-multi-selected workspace ids; when >1 are selected the main pane
// shows a "coming soon" placeholder instead of the conversation.
const selectedWorkspaceIds = ref<string[]>([]);
@ -561,6 +607,12 @@ const showAddWorkspace = ref(false);
const showStatusPanel = ref(false);
const showSettings = ref(false);
type SubmitPayload = {
text: string;
attachments: { fileId: string; kind: 'image' | 'video' }[];
};
const pendingWorkspaceSubmit = ref<SubmitPayload | null>(null);
// Any of these modal/overlay layers, when open, owns Escape. The global
// capture-phase handler must NOT close a background side panel out from under an
// open dialog otherwise Escape dismisses the panel behind the dialog and the
@ -785,15 +837,36 @@ function handleEditQueued(index: number): void {
client.unqueue(index);
}
async function handleSubmit(payload: { text: string; attachments: { fileId: string; kind: 'image' | 'video' }[] }): Promise<void> {
async function handleSubmit(payload: SubmitPayload): Promise<void> {
const wsId = client.activeWorkspaceId.value;
if (!client.activeSessionId.value && wsId) {
await client.startSessionAndSendPrompt(wsId, payload.text, payload.attachments);
return;
}
if (!client.activeSessionId.value && !wsId) {
pendingWorkspaceSubmit.value = payload;
showAddWorkspace.value = true;
return;
}
void client.sendPrompt(payload.text, payload.attachments);
}
async function handleAddWorkspace(root: string): Promise<void> {
showAddWorkspace.value = false;
await client.addWorkspaceByPath(root);
const pending = pendingWorkspaceSubmit.value;
pendingWorkspaceSubmit.value = null;
const wsId = client.activeWorkspaceId.value;
if (pending && wsId) {
await client.startSessionAndSendPrompt(wsId, pending.text, pending.attachments);
}
}
function handleCloseAddWorkspace(): void {
pendingWorkspaceSubmit.value = null;
showAddWorkspace.value = false;
}
// Primary "+ New": enter the draft state in the current workspace so the
// right pane shows the onboarding composer. The session is only created when
// the user sends the first message.
@ -821,23 +894,36 @@ function openPr(url: string): void {
<template>
<div class="app-shell">
<!-- Onboarding banner: shown when the daemon has no auth configured. It sits
in the layout flow at the very top so it reserves height instead of
overlapping the desktop ChatHeader / mobile top bar. -->
<div v-if="!authReady" class="auth-banner">
<div class="auth-banner-inner">
<div class="auth-banner-icon">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="var(--blue)" stroke-width="1.5">
<circle cx="10" cy="10" r="8"/>
<line x1="10" y1="6" x2="10" y2="10"/>
<circle cx="10" cy="13" r="1" fill="var(--blue)"/>
</svg>
<section v-if="showAuthGate" class="auth-page">
<div class="auth-page-inner">
<svg ref="authLogoRef" class="auth-page-logo ch-logo" viewBox="0 0 32 22" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="Kimi Code" @mousedown.prevent @click="blinkAuthLogo">
<defs>
<mask id="authKimiEyes" maskUnits="userSpaceOnUse">
<rect x="0" y="0" width="32" height="22" fill="#fff" />
<g class="ch-eyes" fill="#000">
<rect class="ch-eye" x="11.8" y="7" width="2.8" height="8" rx="1.4" />
<rect class="ch-eye" x="17.4" y="7" width="2.8" height="8" rx="1.4" />
</g>
</mask>
</defs>
<rect x="1" y="1" width="30" height="20" rx="6" fill="var(--logo)" mask="url(#authKimiEyes)" />
</svg>
<div class="auth-page-copy">
<h1>{{ t('app.authPageTitle') }}</h1>
<p>{{ t('app.authPageMessage') }}</p>
</div>
<span class="auth-banner-msg">{{ t('app.authBannerMessage') }}</span>
<button class="auth-banner-btn" @click="openLogin">{{ t('app.authBannerLogin') }}</button>
<button type="button" class="auth-page-btn" @click="openLogin">
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M6 3h5a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H6" />
<path d="M9 8H2" />
<path d="M5 5l3 3-3 3" />
</svg>
<span>{{ t('app.authPageLogin') }}</span>
</button>
</div>
</div>
</section>
<div
v-else
class="app"
:class="{ mobile: isMobile, 'sidebar-collapsed': sidebarCollapsed && !isMobile }"
:style="{ '--side-w': sideWidth + 'px', '--preview-w': previewWidth + 'px' }"
@ -1120,16 +1206,6 @@ function openPr(url: string): void {
@close="showProviders = false"
/>
<!-- Login Dialog overlay -->
<LoginDialog
v-if="showLogin"
:on-start-o-auth-login="handleStartOAuthLogin"
:on-poll-o-auth-login="handlePollOAuthLogin"
:on-cancel-o-auth-login="handleCancelOAuthLogin"
@success="handleLoginSuccess"
@close="showLogin = false"
/>
<!-- New Session Dialog overlay (fallback cwd-typing path) -->
<NewSessionDialog
v-if="showNewSession"
@ -1166,8 +1242,8 @@ function openPr(url: string): void {
:browse-fs="client.browseFs"
:get-fs-home="client.getFsHome"
:default-path="client.visibleWorkspace.value?.root ?? client.status.value.cwd"
@add="(root) => { showAddWorkspace = false; void client.addWorkspaceByPath(root); }"
@close="showAddWorkspace = false"
@add="handleAddWorkspace($event)"
@close="handleCloseAddWorkspace"
/>
<!-- Global connecting splash on first load (until the daemon round-trips) -->
@ -1177,7 +1253,7 @@ function openPr(url: string): void {
<!-- First-run onboarding overlay (theme / language / welcome greeting) -->
<Onboarding
v-if="showOnboarding"
v-if="showOnboarding && !showAuthGate"
:theme="client.theme.value"
@set-theme="client.setTheme($event)"
@complete="completeOnboarding"
@ -1231,10 +1307,19 @@ function openPr(url: string): void {
@set-color-scheme="client.setColorScheme($event)"
@set-ui-font-size="client.setUiFontSize($event)"
@set-beta-toc="client.setBetaToc($event)"
@login="openLogin"
@login="() => { showMobileSettings = false; openLogin(); }"
@logout="client.logout"
/>
</div>
<!-- Login Dialog overlay. It is outside `.app` so `/login` can open it too. -->
<LoginDialog
v-if="showLogin"
:on-start-o-auth-login="handleStartOAuthLogin"
:on-poll-o-auth-login="handlePollOAuthLogin"
:on-cancel-o-auth-login="handleCancelOAuthLogin"
@success="handleLoginSuccess"
@close="showLogin = false"
/>
</div>
</template>
@ -1243,8 +1328,6 @@ function openPr(url: string): void {
.gload-fade-leave-active { transition: opacity 0.28s ease; }
.gload-fade-leave-to { opacity: 0; }
/* Outer shell: the auth banner (when shown) stacks above the app grid in normal
flow so it reserves height instead of overlapping the header/top bar. */
.app-shell {
height: 100vh;
display: flex;
@ -1252,6 +1335,79 @@ function openPr(url: string): void {
overflow: hidden;
box-sizing: border-box;
}
.auth-page {
flex: 1;
min-height: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 32px;
background: var(--bg);
color: var(--ink);
box-sizing: border-box;
}
.auth-page-inner {
width: min(420px, 100%);
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 18px;
}
.auth-page-logo {
width: 64px;
height: 44px;
flex: none;
cursor: pointer;
user-select: none;
-webkit-user-select: none;
transition: transform 0.18s ease;
}
.auth-page-logo:hover {
transform: scale(1.06);
}
.auth-page-copy {
display: flex;
flex-direction: column;
gap: 8px;
}
.auth-page-copy h1 {
margin: 0;
font-family: var(--sans);
font-size: 30px;
line-height: 1.15;
font-weight: 650;
letter-spacing: 0;
color: var(--ink);
}
.auth-page-copy p {
margin: 0;
font-family: var(--sans);
font-size: var(--ui-font-size-lg);
line-height: 1.55;
color: var(--dim);
}
.auth-page-btn {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 38px;
padding: 8px 14px;
border: 1px solid var(--blue);
border-radius: 8px;
background: var(--blue);
color: var(--bg);
font-family: var(--mono);
font-size: var(--ui-font-size);
cursor: pointer;
}
.auth-page-btn:hover {
background: var(--blue2);
border-color: var(--blue2);
}
.auth-page-btn:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.app {
--side-w: 248px;
--preview-w: 460px;
@ -1359,36 +1515,6 @@ function openPr(url: string): void {
border-top: 2px solid var(--ink);
}
/* Auth onboarding banner in-flow at the top of the shell (full width, above
both the desktop sidebar/header and the mobile top bar). */
.auth-banner {
flex: none;
background: var(--soft);
border-bottom: 1px solid var(--bd);
}
.auth-banner-inner {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 16px;
font-family: var(--mono);
font-size: var(--ui-font-size);
}
.auth-banner-icon { display: flex; align-items: center; flex: none; }
.auth-banner-msg { flex: 1; color: var(--text); }
.auth-banner-btn {
background: var(--blue);
border: none;
border-radius: 3px;
font-family: var(--mono);
font-size: var(--ui-font-size-xs);
padding: 4px 14px;
color: var(--bg);
cursor: pointer;
flex: none;
}
.auth-banner-btn:hover { background: var(--blue2); }
/* Multi-workspace selection placeholder */
.coming-soon {
display: flex;
@ -1405,24 +1531,20 @@ function openPr(url: string): void {
.cs-text { font-size: var(--ui-font-size); }
@media (max-width: 640px) {
.auth-banner-inner {
.auth-page {
align-items: flex-start;
flex-wrap: wrap;
padding:
8px
max(12px, env(safe-area-inset-right))
8px
max(12px, env(safe-area-inset-left));
max(48px, env(safe-area-inset-top))
max(20px, env(safe-area-inset-right))
max(24px, env(safe-area-inset-bottom))
max(20px, env(safe-area-inset-left));
}
.auth-banner-msg {
min-width: 0;
flex: 1 1 calc(100% - 34px);
line-height: 1.45;
.auth-page-copy h1 {
font-size: 26px;
}
.auth-banner-btn {
margin-left: 24px;
max-width: calc(100% - 24px);
white-space: normal;
.auth-page-btn {
width: 100%;
justify-content: center;
}
}
</style>

View file

@ -126,6 +126,8 @@ const activeWorkspaceLabel = computed(() => {
return w?.name ?? props.workspaceName ?? '';
});
const hasWorkspaces = computed(() => (props.workspaces?.length ?? 0) > 0);
const visibleWorkspaces = computed(() =>
getVisibleWorkspaces(props.workspaces ?? [], props.activeWorkspaceId, wsPickExpanded.value),
);
@ -843,7 +845,7 @@ defineExpose({ loadComposerForEdit });
<span class="empty-hint-title">{{ t('composer.emptyConversationTitle') }}</span>
<span class="empty-hint-text">{{ t('composer.emptyConversation') }}</span>
<!-- Workspace picker: choose where this new conversation starts. -->
<div v-if="(workspaces?.length ?? 0) > 0" class="ws-pick">
<div v-if="hasWorkspaces" class="ws-pick">
<button type="button" class="ws-pick-btn" :title="t('conversation.switchWorkspace')" @click.stop="wsPickOpen = !wsPickOpen">
<svg viewBox="0 0 14 14" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.2" aria-hidden="true">
<path d="M1 3.5V2.5A1 1 0 0 1 2 1.5h3.5l1.3 2h5.2a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/>
@ -888,6 +890,19 @@ defineExpose({ loadComposerForEdit });
</button>
</div>
</div>
<button
v-else
type="button"
class="empty-add-workspace"
@click="emit('addWorkspace')"
>
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true">
<path d="M1 3.5V2.5A1 1 0 0 1 2 1.5h3.5l1.3 2h5.2a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H2a1 1 0 0 1-1-1z"/>
<path d="M1 5.5h12"/>
<path d="M8 7.25v4.5M5.75 9.5h4.5"/>
</svg>
<span>{{ t('conversation.addWorkspace') }}</span>
</button>
</div>
<Composer
ref="emptyComposerRef"
@ -1286,6 +1301,32 @@ defineExpose({ loadComposerForEdit });
text-overflow: ellipsis;
white-space: nowrap;
}
.empty-add-workspace {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
min-height: 34px;
padding: 7px 12px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--dim);
font-family: var(--mono);
font-size: var(--ui-font-size-sm);
cursor: pointer;
}
.empty-add-workspace:hover {
border-color: var(--bd);
color: var(--ink);
}
.empty-add-workspace:focus-visible {
outline: 2px solid var(--blue);
outline-offset: 2px;
}
.empty-add-workspace svg {
flex: none;
}
/* Empty-composer workspace picker */
.ws-pick {

View file

@ -354,10 +354,10 @@ function formatSeconds(s: number): string {
}
.dialog {
position: relative;
background: var(--bg);
border: 1px solid var(--line);
border-top: 2px solid var(--blue);
border-radius: 4px;
border-radius: 6px;
width: 480px;
max-width: calc(100vw - 32px);
height: 420px;
@ -366,6 +366,7 @@ function formatSeconds(s: number): string {
flex-direction: column;
font-family: var(--mono);
box-shadow: 0 8px 32px rgba(0,0,0,0.14);
overflow: hidden;
}
/* Header */

View file

@ -1,6 +1,9 @@
export default {
authBannerMessage: 'Not signed in · Sign in to Kimi Code to start a conversation',
authBannerLogin: 'Sign in',
authPageTitle: 'Sign in to Kimi Code',
authPageMessage: 'Connect your Kimi Code account before starting or continuing conversations.',
authPageLogin: 'Sign in',
connecting: 'Connecting…',
comingSoon: 'Coming soon…',
} as const;

View file

@ -1,6 +1,9 @@
export default {
authBannerMessage: '未登录 · 需要登录 Kimi Code 才能开始对话',
authBannerLogin: '登录',
authPageTitle: '登录 Kimi Code',
authPageMessage: '先连接 Kimi Code 账号,然后再开始或继续对话。',
authPageLogin: '登录',
connecting: '连接中…',
comingSoon: '敬请期待',
} as const;

View file

@ -74,6 +74,18 @@ afterEach(() => {
});
describe('ConversationPane empty-session send', () => {
it('offers an add-workspace action when no workspace exists', async () => {
const wrapper = mountPane({ workspaces: [], activeWorkspaceId: null });
await nextTick();
const addWorkspace = wrapper.find('.empty-add-workspace');
expect(addWorkspace.exists()).toBe(true);
await addWorkspace.trigger('click');
expect(wrapper.emitted('addWorkspace')).toHaveLength(1);
});
it('clears the empty composer and keeps the new-session draft empty after send', async () => {
const wrapper = mountPane({ sessionId: '' });
await nextTick();