diff --git a/.changeset/web-login-workspace-flow.md b/.changeset/web-login-workspace-flow.md new file mode 100644 index 000000000..c22a517c7 --- /dev/null +++ b/.changeset/web-login-workspace-flow.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the web login page and no-workspace conversation startup flow. diff --git a/apps/kimi-web/src/App.vue b/apps/kimi-web/src/App.vue index 85b0a95ce..11311d8a3 100644 --- a/apps/kimi-web/src/App.vue +++ b/apps/kimi-web/src/App.vue @@ -61,6 +61,53 @@ const activeWorkspaceSessionCount = computed( // 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(null); +const authLogoRef = ref(null); +let authLogoBlinkTimer: ReturnType | 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(() => { 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 | 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([]); @@ -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(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 { +async function handleSubmit(payload: SubmitPayload): Promise { 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 { + 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 { @@ -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; } } diff --git a/apps/kimi-web/src/components/ConversationPane.vue b/apps/kimi-web/src/components/ConversationPane.vue index 2a81a0e75..22fdca20a 100644 --- a/apps/kimi-web/src/components/ConversationPane.vue +++ b/apps/kimi-web/src/components/ConversationPane.vue @@ -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 }); {{ t('composer.emptyConversationTitle') }} {{ t('composer.emptyConversation') }} -
+
+ { }); 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();