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
26 changes: 25 additions & 1 deletion apps/desktop/e2e/first-run.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from './fixtures';
import { PROVIDER_REGISTRY, RECOMMENDED_PROVIDER_TYPES } from '@maka/core';

/**
* First-run flow: a brand-new workspace (empty userData) must boot to the main
Expand All @@ -7,7 +8,30 @@ import { test, expect } from './fixtures';
* E2E isolation seam (MAKA_E2E_USER_DATA_DIR) and the fake-backend switch
* (MAKA_E2E) that the rest of the suite depends on.
*/
test('boots to the main window on a fresh workspace', async ({ emptyWindow: page }) => {
test('boots to registry recommendations and browses the shared provider catalog', async ({ emptyWindow: page }) => {
await expect(page).toHaveTitle('Maka');
await expect(page.locator('#root')).not.toBeEmpty();

const providerRows = page.locator('.maka-firstrun-row');
await expect(providerRows).toHaveCount(RECOMMENDED_PROVIDER_TYPES.length);
await expect(providerRows).toContainText(
RECOMMENDED_PROVIDER_TYPES.map((type) => PROVIDER_REGISTRY[type].label),
);

await page.getByRole('button', { name: '浏览全部服务商' }).click();

await expect(page.getByLabel('设置内容')).toBeVisible();
await expect(page.getByRole('heading', { name: '添加服务商' })).toBeVisible();
await expect(page.getByRole('tablist', { name: '模型供应商分类' })).toBeVisible();
await expect(page.getByPlaceholder('搜索服务商')).toBeVisible();

await page.getByRole('button', { name: '返回模型连接' }).click();
await expect(page.getByRole('button', { name: '添加服务商' })).toBeVisible();

const settingsNav = page.locator('[aria-label="设置分组"]');
await settingsNav.getByText('外观', { exact: true }).click();
await settingsNav.getByText('模型', { exact: true }).click();

await expect(page.getByRole('button', { name: '添加服务商' })).toBeVisible();
await expect(page.getByPlaceholder('搜索服务商')).toHaveCount(0);
});
8 changes: 8 additions & 0 deletions apps/desktop/src/main/__tests__/onboarding-hero-copy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,14 @@ describe('getOnboardingSetupSteps — first-run AI setup guide', () => {
});

describe('OnboardingHero Quick Chat draft lifecycle', () => {
it('renders first-run provider recommendations from the shared registry', async () => {
const hero = await readFile(new URL('../../../src/renderer/OnboardingHero.tsx', import.meta.url), 'utf8');

assert.match(hero, /RECOMMENDED_PROVIDER_TYPES/);
assert.match(hero, /RECOMMENDED_PROVIDER_TYPES\.map\(\(type\) =>/);
assert.doesNotMatch(hero, /const FEATURED\s*=/, 'onboarding must not own a parallel provider list');
});

it('keeps first-run form controls on shared UI primitives', async () => {
const hero = await readFile(new URL('../../../src/renderer/OnboardingHero.tsx', import.meta.url), 'utf8');
const checklist = await readFile(new URL('../../../src/renderer/FirstRunChecklist.tsx', import.meta.url), 'utf8');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { resolve } from 'node:path';
import { describe, test } from 'node:test';

const PANEL = resolve(import.meta.dirname, '../../../src/renderer/settings/ProvidersPanel.tsx');
const SETTINGS_SURFACE = resolve(import.meta.dirname, '../../../src/renderer/settings/settings-surface.tsx');
const PROVIDER_CSS = resolve(import.meta.dirname, '../../../src/renderer/styles/settings/provider-editor.css');

describe('Settings model provider page hierarchy', () => {
Expand Down Expand Up @@ -49,4 +50,21 @@ describe('Settings model provider page hierarchy', () => {
'catalog search must stay compact without collapsing below the standard control height',
);
});

test('consumes an external catalog request after its first loaded model-page mount', async () => {
const source = await readFile(SETTINGS_SURFACE, 'utf8');

assert.match(source, /useState\(props\.openProviderCatalog === true\)/);
assert.match(
source,
/if \(!loading && section === 'models' && providerCatalogRequested\) \{\s*setProviderCatalogRequested\(false\);\s*\}/,
'the first loaded model page must consume the one-shot catalog intent',
);
assert.match(source, /openProviderCatalog=\{providerCatalogRequested\}/);
assert.doesNotMatch(
source,
/openProviderCatalog=\{props\.openProviderCatalog\}/,
'the shell request must not be replayed every time the model page remounts',
);
});
});
39 changes: 14 additions & 25 deletions apps/desktop/src/renderer/OnboardingHero.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@

import { ArrowRight, ArrowUp, ChevronRight, RotateCcw, Sparkles, KeyRound, Settings as SettingsIcon, Cpu, AlertCircle, FolderOpen, Paperclip, X } from '@maka/ui/icons';
import { Fragment, useCallback, useEffect, useRef, useState, type ClipboardEvent, type DragEvent, type KeyboardEvent } from 'react';
import type { LlmConnection, OnboardingState, ProviderType, QuickChatMode, SettingsSection } from '@maka/core';
import { RECOMMENDED_PROVIDER_TYPES, type LlmConnection, type OnboardingState, type QuickChatMode, type SettingsSection } from '@maka/core';
import {
Button,
Item,
Expand Down Expand Up @@ -91,23 +91,12 @@ const READY_HERO_COPY_BY_LOCALE: Record<UiLocale, {
},
};

// Titles are PROVIDER-forward and version-free (no `GPT-4o` / `DeepSeek-V3`
// — those go stale). The row description comes from `providerDisplay` so
// copy has a single source of truth shared with Settings · 模型.
const FEATURED: Array<{ type: ProviderType; tag: string; recommended?: boolean }> = [
{ type: 'anthropic', tag: 'Claude · Anthropic', recommended: true },
{ type: 'openai', tag: 'OpenAI' },
{ type: 'zai-coding-plan', tag: 'GLM Coding Plan · Z.ai' },
{ type: 'MiniMax', tag: 'MiniMax M-series' },
{ type: 'kimi-coding-plan', tag: 'Kimi · Moonshot' },
{ type: 'deepseek', tag: 'DeepSeek' },
{ type: 'ollama', tag: 'Ollama' },
];

export interface OnboardingHeroProps {
state: OnboardingState;
/** Open Settings with a specific section preselected. */
onOpenSettings: (section?: SettingsSection) => void;
/** Open the shared Settings provider catalog. */
onBrowseProviders: () => void;
/**
* Quick Chat submit handler (PR110b `quickChat:start`). Only
* called from the `ready_empty` branch. The caller is responsible
Expand Down Expand Up @@ -174,6 +163,7 @@ export function OnboardingHero(props: OnboardingHeroProps) {
return (
<NeedsConnectionHero
onOpenSettings={props.onOpenSettings}
onBrowseProviders={props.onBrowseProviders}
onRefreshConnections={props.onRefreshConnections ? runRefreshConnections : undefined}
refreshConnectionsPending={refreshConnectionsPending}
onSkip={props.onSkip}
Expand Down Expand Up @@ -260,6 +250,7 @@ function connectionLabel(

function NeedsConnectionHero(props: {
onOpenSettings: (section?: SettingsSection) => void;
onBrowseProviders: () => void;
onRefreshConnections?: () => void;
refreshConnectionsPending?: boolean;
onSkip?: () => Promise<void> | void;
Expand All @@ -284,10 +275,10 @@ function NeedsConnectionHero(props: {
providers are added without pushing the footer off-screen. */}
<div className="maka-firstrun-list">
<ul role="list">
{FEATURED.map((entry) => {
const display = providerDisplay(entry.type);
{RECOMMENDED_PROVIDER_TYPES.map((type) => {
const display = providerDisplay(type);
return (
<li key={entry.type}>
<li key={type}>
<Item
className="maka-firstrun-row px-3.5 py-2"
render={
Expand All @@ -298,15 +289,10 @@ function NeedsConnectionHero(props: {
}
>
<ItemMedia>
<ProviderLogo type={entry.type} compact />
<ProviderLogo type={type} compact />
</ItemMedia>
<ItemContent>
<ItemTitle>
{entry.tag}
{entry.recommended && (
<span className="maka-firstrun-tag">常用</span>
)}
</ItemTitle>
<ItemTitle>{display.name}</ItemTitle>
<ItemDescription>{display.description}</ItemDescription>
</ItemContent>
<ItemActions>
Expand All @@ -322,8 +308,11 @@ function NeedsConnectionHero(props: {
{/* Designer audit P2-15: the footer's primary 打开设置·模型 button
duplicated what clicking any provider row above already does (the
list header even says 点一个进入设置). One affordance per action —
the footer keeps only the two genuinely distinct paths. */}
the footer keeps only genuinely distinct paths. */}
<footer className="maka-onboarding-footer">
<Button type="button" variant="outline" onClick={props.onBrowseProviders}>
浏览全部服务商
</Button>
{props.onRefreshConnections && (
<Button
type="button"
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/renderer/app-shell-overlays.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function AppShellOverlays(props: {
setThemePalette(themePalette: ThemePalette): void;
setUserLabel(userLabel: string): void;
settingsRequestedSection: SettingsSection | undefined;
settingsProviderCatalogOpen: boolean;
onOpenDailyReview(): void;
onOpenSettingsSession(sessionId: string): void;
helpOpen: boolean;
Expand Down Expand Up @@ -85,6 +86,7 @@ export function AppShellOverlays(props: {
searchModalOpen,
settingsOpen,
settingsRequestedSection,
settingsProviderCatalogOpen,
setThemePalette,
setThemePref,
setUserLabel,
Expand Down Expand Up @@ -113,6 +115,7 @@ export function AppShellOverlays(props: {
onThemePaletteChange={setThemePalette}
onUserLabelChange={setUserLabel}
requestedSection={settingsRequestedSection}
openProviderCatalog={settingsProviderCatalogOpen}
onOpenDailyReview={props.onOpenDailyReview}
onOpenSession={props.onOpenSettingsSession}
/>
Expand Down
13 changes: 13 additions & 0 deletions apps/desktop/src/renderer/app-shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export function AppShell({
} = useShellConnections({ toastApi });
const [settingsOpen, setSettingsOpen] = useState(false);
const [settingsRequestedSection, setSettingsRequestedSection] = useState<SettingsSection | undefined>(undefined);
const [settingsProviderCatalogOpen, setSettingsProviderCatalogOpen] = useState(false);
const [themePref, setThemePref] = useState<ThemePreference>('auto');
const [themePalette, setThemePalette] = useState<ThemePalette>('default');
const [userLabel, setUserLabel] = useState<string>('');
Expand Down Expand Up @@ -1159,6 +1160,7 @@ export function AppShell({
}

function openSettings() {
setSettingsProviderCatalogOpen(false);
setSettingsOpen(true);
}

Expand Down Expand Up @@ -1206,11 +1208,20 @@ export function AppShell({
function openSettingsSection(section: SettingsSection) {
safeLocalStorageSet('maka-settings-section-v1', section);
setSettingsRequestedSection(section);
setSettingsProviderCatalogOpen(false);
setSettingsOpen(true);
}

function openProviderCatalog() {
safeLocalStorageSet('maka-settings-section-v1', 'models');
setSettingsRequestedSection('models');
setSettingsProviderCatalogOpen(true);
setSettingsOpen(true);
}

function closeSettings() {
setSettingsOpen(false);
setSettingsProviderCatalogOpen(false);
// PR110c: re-pull onboarding snapshot when the user closes the
// Settings modal — they may have just configured a default
// connection or supplied a credential. Existing connections /
Expand Down Expand Up @@ -1474,6 +1485,7 @@ export function AppShell({
if (section) openSettingsSection(section);
else openSettings();
}}
onBrowseProviders={openProviderCatalog}
onQuickChatSubmit={handleQuickChatSubmit}
quickChatPending={quickChatPending}
connections={connections}
Expand Down Expand Up @@ -1640,6 +1652,7 @@ export function AppShell({
setThemePalette={setThemePalette}
setUserLabel={setUserLabel}
settingsRequestedSection={settingsRequestedSection}
settingsProviderCatalogOpen={settingsProviderCatalogOpen}
onOpenDailyReview={() => {
closeSettings();
setNavSelection({ section: 'daily-review' });
Expand Down
7 changes: 5 additions & 2 deletions apps/desktop/src/renderer/settings/ProvidersPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,13 @@ const CATALOG_TABS: Array<{ id: CatalogCategory; label: string }> = [
{ id: 'local', label: '本地' },
];

export function ProvidersPanel({ bridge }: { bridge: ConnectionsBridge }) {
export function ProvidersPanel({ bridge, initialPage = 'connections' }: {
bridge: ConnectionsBridge;
initialPage?: 'connections' | 'catalog';
}) {
const [connections, setConnections] = useState<LlmConnection[]>([]);
const [defaultSlug, setDefaultSlug] = useState<string | null>(null);
const [page, setPage] = useState<ProviderPage>({ kind: 'connections' });
const [page, setPage] = useState<ProviderPage>({ kind: initialPage });
const [catalogCategory, setCatalogCategory] = useState<CatalogCategory>('recommended');
const [catalogQuery, setCatalogQuery] = useState('');
const [loading, setLoading] = useState(true);
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/src/renderer/settings/SettingsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function SettingsModal(props: {
* ⌘K → "网络" jumps straight to the section without an extra click.
*/
requestedSection?: SettingsSection;
openProviderCatalog?: boolean;
/**
* PR-DAILY-REVIEW-MVP-0 follow-up: navigate to the sidebar's
* Daily Review module. Optional so the settings page degrades
Expand Down Expand Up @@ -84,6 +85,7 @@ export function SettingsModal(props: {
onThemePaletteChange={props.onThemePaletteChange}
onUserLabelChange={props.onUserLabelChange}
requestedSection={props.requestedSection}
openProviderCatalog={props.openProviderCatalog}
initialFocusRef={activeNavRef}
onOpenDailyReview={props.onOpenDailyReview}
onOpenSession={props.onOpenSession}
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/renderer/settings/settings-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,11 +44,13 @@ export function SettingsSurface(props: {
onThemePaletteChange(palette: ThemePalette): void;
onUserLabelChange?(label: string): void;
requestedSection?: SettingsSection;
openProviderCatalog?: boolean;
initialFocusRef: RefObject<HTMLButtonElement | null>;
onOpenDailyReview?(): void;
onOpenSession?(sessionId: string): void;
}) {
const [section, setSection] = useState<SettingsSection>(() => props.requestedSection ?? readLastSettingsSection());
const [providerCatalogRequested, setProviderCatalogRequested] = useState(props.openProviderCatalog === true);

// When the parent updates requestedSection (e.g. the palette opens
// Settings with a different section while it's already mounted), reflect
Expand Down Expand Up @@ -108,6 +110,12 @@ export function SettingsSurface(props: {
const usageReloadTicketRef = useRef(0);
const toast = useToast();

useEffect(() => {
if (!loading && section === 'models' && providerCatalogRequested) {
setProviderCatalogRequested(false);
}
}, [loading, providerCatalogRequested, section]);

useEffect(() => {
settingsModalMountedRef.current = true;
return () => {
Expand Down Expand Up @@ -257,6 +265,7 @@ export function SettingsSurface(props: {
onThemePaletteChange={props.onThemePaletteChange}
onOpenDailyReview={props.onOpenDailyReview}
onOpenSession={props.onOpenSession}
openProviderCatalog={providerCatalogRequested}
/>
)}
</OverlayScrollArea>
Expand All @@ -281,6 +290,7 @@ function SettingsPage(props: {
onThemePaletteChange(palette: ThemePalette): void;
onOpenDailyReview?(): void;
onOpenSession?(sessionId: string): void;
openProviderCatalog?: boolean;
}) {
// PR-FE-BUG-HUNT-0 (kenji bug-hunt 2026-06-24): the inline `void
// props.onUpdateSettings(...)` at the privacy toggle below
Expand All @@ -292,7 +302,10 @@ function SettingsPage(props: {
case 'models':
return (
<div className="settingsStructuredPage settingsModelsPage">
<ProvidersPanel bridge={window.maka.connections} />
<ProvidersPanel
bridge={window.maka.connections}
initialPage={props.openProviderCatalog ? 'catalog' : 'connections'}
/>
</div>
);
case 'usage':
Expand Down
12 changes: 0 additions & 12 deletions apps/desktop/src/renderer/styles/onboarding.css
Original file line number Diff line number Diff line change
Expand Up @@ -250,18 +250,6 @@
border-top: var(--border-width-hairline) solid var(--border);
}

/* --- "常用" tag inside a provider row title --- */
.maka-firstrun-tag {
flex: 0 0 auto;
font-size: var(--font-size-caption);
font-weight: var(--font-weight-semibold);
line-height: var(--leading-none);
color: var(--nav-active);
background: oklch(from var(--nav-active) l c h / 0.12);
padding: var(--space-0-5) var(--space-1-5);
border-radius: var(--radius-control);
}

.maka-firstrun .maka-onboarding-footer {
margin-top: var(--space-6);
}
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/stories/app-shell.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ export const EmptyHome: Story = {
<OnboardingHero
state={{ kind: 'ready_empty', defaultConnectionSlug: 'anthropic-main', defaultModel: 'claude-sonnet-4-5' }}
onOpenSettings={noop}
onBrowseProviders={noop}
onQuickChatSubmit={async () => true}
/>
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/stories/onboarding.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ function heroProps(state: OnboardingState) {
return {
state,
onOpenSettings: (_section?: SettingsSection) => undefined,
onBrowseProviders: () => undefined,
onQuickChatSubmit: async () => true,
connections,
onRefreshConnections: async () => undefined,
Expand Down
Loading