merge: integrate coding default page

# Conflicts:
#	src/components/layout/Sidebar.tsx
#	tests/unit/coding-chat-panel.test.tsx
This commit is contained in:
inman
2026-09-07 10:10:34 +08:00
12 changed files with 487 additions and 36 deletions

View File

@@ -218,7 +218,7 @@ function ProjectChatRoute() {
if (!project) {
if (!cancelled) {
setResolvedProjectKey(null);
setResolvedRoute('config');
setResolvedRoute('chat');
}
return;
}

View File

@@ -31,6 +31,10 @@ import {
import { getAiModuleForPath } from '@/lib/ai-modules';
import { invokeIpc } from '@/lib/api-client';
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
import {
subscribeCodingProjectCreationRequest,
subscribeCodingProjectOpenRequest,
} from '@/lib/coding-project-entry';
import { WORKS_SQUARE_TOKEN_USAGE_STALE_EVENT } from '@/lib/works-square-usage-events';
import { fetchWorksTokenUsage, type WorksTokenUsage } from '@/lib/works-square';
import { isWorksTokenUsageExhausted } from '@/lib/works-square-token-usage';
@@ -385,13 +389,18 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
}, [authUser, refreshTokenUsage]);
const openCreateProject = () => {
const openCreateProject = useCallback(() => {
setNewProjectName('');
setNewProjectSelectedPath('');
setNewProjectDirectoryMode('use-selected-directory');
setCreateProjectError(null);
setCreateDialogOpen(true);
};
}, []);
useEffect(() => {
if (!isProgrammingModule) return undefined;
return subscribeCodingProjectCreationRequest(openCreateProject);
}, [isProgrammingModule, openCreateProject]);
const closeCreateProject = () => {
if (creatingProject) return;
@@ -411,11 +420,11 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
setCreateProjectError(null);
};
const showProjectEntryError = (project: CodingProjectSummary, message: string) => {
const showProjectEntryError = useCallback((project: CodingProjectSummary, message: string) => {
setProjectEntryError({ project, message });
};
}, []);
const enterProject = async (project: CodingProjectSummary) => {
const enterProject = useCallback(async (project: CodingProjectSummary) => {
const result = await loadProjectConfig(project.id);
if (result.status !== 'valid' || !result.config) {
showProjectEntryError(project, result.status === 'missing' ? '项目缺少必要的配置文件' : result.error ?? '项目配置无效');
@@ -423,16 +432,16 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
}
await setActiveProject(project.id);
navigate('/chat');
};
}, [loadProjectConfig, navigate, setActiveProject, showProjectEntryError]);
const readAndEnterProject = async (project: CodingProjectSummary) => {
const readAndEnterProject = useCallback(async (project: CodingProjectSummary) => {
setProjectEntryError(null);
try {
await enterProject(project);
} catch (error) {
showProjectEntryError(project, error instanceof Error ? error.message : String(error));
}
};
}, [enterProject, showProjectEntryError]);
const confirmCreateProject = async () => {
@@ -465,9 +474,17 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
}
};
const openProject = async (project: CodingProjectSummary) => {
const openProject = useCallback(async (project: CodingProjectSummary) => {
await readAndEnterProject(project);
};
}, [readAndEnterProject]);
useEffect(() => {
if (!isProgrammingModule) return undefined;
return subscribeCodingProjectOpenRequest((projectId) => {
const project = visibleProjects.find((candidate) => candidate.id === projectId);
if (project) void openProject(project);
});
}, [isProgrammingModule, openProject, visibleProjects]);
const requestRemoveUnavailableProject = () => {
if (!projectEntryError) return;

View File

@@ -0,0 +1,32 @@
const CODING_PROJECT_CREATE_REQUEST_EVENT = 'makelore:coding-project-create-request';
const CODING_PROJECT_OPEN_REQUEST_EVENT = 'makelore:coding-project-open-request';
export function requestCodingProjectCreation(): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new Event(CODING_PROJECT_CREATE_REQUEST_EVENT));
}
export function subscribeCodingProjectCreationRequest(listener: () => void): () => void {
if (typeof window === 'undefined') return () => undefined;
window.addEventListener(CODING_PROJECT_CREATE_REQUEST_EVENT, listener);
return () => window.removeEventListener(CODING_PROJECT_CREATE_REQUEST_EVENT, listener);
}
export function requestCodingProjectOpen(projectId: string): void {
if (typeof window === 'undefined') return;
window.dispatchEvent(new CustomEvent(CODING_PROJECT_OPEN_REQUEST_EVENT, {
detail: { projectId },
}));
}
export function subscribeCodingProjectOpenRequest(
listener: (projectId: string) => void,
): () => void {
if (typeof window === 'undefined') return () => undefined;
const handleRequest = (event: Event) => {
const projectId = (event as CustomEvent<{ projectId?: unknown }>).detail?.projectId;
if (typeof projectId === 'string' && projectId.trim()) listener(projectId);
};
window.addEventListener(CODING_PROJECT_OPEN_REQUEST_EVENT, handleRequest);
return () => window.removeEventListener(CODING_PROJECT_OPEN_REQUEST_EVENT, handleRequest);
}

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
Bot,
ChevronRight,
CircleAlert,
FolderKanban,
Plus,
RefreshCw,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -33,6 +35,7 @@ import type {
} from '@/types/coding-conversation';
import type {
CodingConversationMetadata,
CodingProjectSummary,
} from '@/types/coding-project';
import { CodingComposer } from './CodingComposer';
import { CodingChangesSummary } from './CodingChangesSummary';
@@ -40,10 +43,13 @@ import { CodingConversationSidebar } from './CodingConversationSidebar';
import { CodingConversationHeader } from './CodingConversationHeader';
import { CodingConversationTimeline } from './CodingConversationTimeline';
import { CodingInteractionPanel } from './CodingInteractionPanel';
import { CodingWelcomeHero } from './CodingWelcomeHero';
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
export interface CodingChatPanelProps {
navigationDraft?: string;
onCreateProject?(): void;
onOpenProject?(projectId: string): void;
onOpenProjectSettings?(): void;
}
@@ -107,10 +113,24 @@ function acceptedPromptCount(
)).length;
}
const PROJECT_DATE_FORMATTER = new Intl.DateTimeFormat('zh-CN', {
month: 'short',
day: 'numeric',
});
function projectLastOpenedLabel(project: CodingProjectSummary): string {
const timestamp = Date.parse(project.lastOpenedAt);
if (!Number.isFinite(timestamp)) return '本地项目';
return `最近打开 ${PROJECT_DATE_FORMATTER.format(timestamp)}`;
}
export function CodingChatPanel({
navigationDraft,
onCreateProject,
onOpenProject,
onOpenProjectSettings,
}: CodingChatPanelProps) {
const projects = useCodingWorkspaceStore((state) => state.projects);
const activeProject = useCodingWorkspaceStore((state) => state.activeProject);
const config = useCodingWorkspaceStore((state) => state.config);
const conversations = useCodingWorkspaceStore((state) => state.conversations);
@@ -592,18 +612,76 @@ export function CodingChatPanel({
if (!activeProject && workspaceLoadState !== 'loading') {
return (
<section className="flex min-h-0 flex-1 items-center justify-center bg-background p-6" data-testid="coding-chat-empty-project">
<div className="max-w-md text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl bg-surface-subtle shadow-[0_0_0_1px_rgba(0,0,0,0.06)]">
<Bot className="h-5 w-5 text-muted-foreground" aria-hidden="true" />
</div>
<h1 className="mt-4 text-balance text-lg font-semibold"></h1>
<p className="mt-2 text-pretty text-sm leading-6 text-muted-foreground">
Pi
</p>
<Button className="mt-5 min-h-10 rounded-xl" onClick={onOpenProjectSettings}>
<section
className="flex min-h-0 flex-1 flex-col overflow-y-auto bg-background px-4 pb-3 pt-4 text-foreground sm:px-6 sm:pb-5"
data-testid="coding-chat-empty-project"
>
<CodingWelcomeHero className="min-h-[18rem] flex-1 pb-8 pt-6 sm:pb-12" />
<div className="mx-auto w-full max-w-[52rem] shrink-0 pb-1">
{workspaceError ? (
<div className="mb-2 flex min-h-10 items-center gap-2 rounded-xl bg-destructive/5 px-3 py-2 text-xs text-destructive">
<CircleAlert className="h-4 w-4 shrink-0" aria-hidden="true" />
<p className="min-w-0 flex-1 text-pretty" role="alert">{workspaceError}</p>
<Button
type="button"
variant="ghost"
className="min-h-8 shrink-0 rounded-lg px-2.5 text-destructive"
onClick={() => void loadWorkspace().catch(() => undefined)}
>
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden="true" />
</Button>
</div>
) : null}
<Button
type="button"
data-testid="coding-start-project-button"
className="mx-auto flex h-12 min-w-44 rounded-xl border border-brand/20 bg-brand px-7 text-[15px] font-semibold text-primary-foreground shadow-[0_10px_24px_rgba(190,68,24,0.18)] transition-[transform,box-shadow,background-color] duration-200 hover:-translate-y-0.5 hover:bg-brand-hover hover:shadow-[0_14px_30px_rgba(190,68,24,0.22)] active:translate-y-0"
onClick={onCreateProject}
disabled={!onCreateProject}
>
<Plus className="mr-2 h-4.5 w-4.5" aria-hidden="true" />
</Button>
{projects.length > 0 ? (
<section className="mt-7" aria-label="项目列表">
<div
className="flex gap-3 overflow-x-auto pb-2"
data-testid="coding-existing-projects"
>
{projects.map((project) => (
<button
type="button"
key={project.id}
data-testid={`coding-existing-project-${project.id}`}
aria-label={`进入项目 ${project.name}`}
className="motion-press group/project-card flex min-h-[6.75rem] w-64 shrink-0 flex-col justify-between rounded-2xl bg-surface-subtle p-4 text-left shadow-[0_8px_22px_rgba(35,43,56,0.055)] ring-1 ring-foreground/[0.055] transition-[transform,box-shadow,background-color] duration-200 hover:-translate-y-0.5 hover:bg-white hover:shadow-[0_14px_30px_rgba(35,43,56,0.09)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-2"
onClick={() => onOpenProject?.(project.id)}
disabled={!onOpenProject}
>
<span className="flex items-start justify-between gap-4">
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white text-brand shadow-[0_0_0_1px_rgba(35,43,56,0.06)] group-hover/project-card:bg-brand-soft">
<FolderKanban className="h-4 w-4" aria-hidden="true" />
</span>
<ChevronRight className="mt-1 h-4 w-4 text-muted-foreground/55 transition-[transform,color] duration-200 group-hover/project-card:translate-x-0.5 group-hover/project-card:text-foreground" aria-hidden="true" />
</span>
<span className="mt-4 min-w-0">
<span className="block truncate text-sm font-semibold text-foreground">{project.name}</span>
<time
dateTime={project.lastOpenedAt}
className="mt-1 block text-xs text-muted-foreground"
>
{projectLastOpenedLabel(project)}
</time>
</span>
</button>
))}
</div>
</section>
) : null}
</div>
</section>
);

View File

@@ -11,7 +11,6 @@ import {
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
Bot,
BookOpen,
Check,
CheckCircle2,
@@ -64,6 +63,7 @@ import type {
SubagentDetailsV1,
} from '../../../shared/coding-conversation-contracts';
import { CodingAttachmentPreview } from './CodingAttachmentPreview';
import { CodingWelcomeHero } from './CodingWelcomeHero';
const EMPTY_NODES: ConversationNode[] = [];
const IDLE_RUN: ConversationRunState = { status: 'idle' };
@@ -1610,7 +1610,10 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli
if (element.scrollTop <= 48) loadEarlier();
}}
>
<div className="mx-auto flex w-full max-w-[50rem] flex-col gap-8">
<div className={cn(
'mx-auto flex w-full max-w-[50rem] flex-col gap-8',
nodes.length === 0 && 'min-h-full',
)}>
{windowStart > 0 && (
<Button
type="button"
@@ -1632,15 +1635,10 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli
/>
))}
{nodes.length === 0 && (
<div className="mx-auto flex max-w-md flex-col items-center px-6 py-16 text-center">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-surface-subtle">
<Bot className="h-4.5 w-4.5 text-muted-foreground" aria-hidden="true" />
</div>
<h2 className="mt-4 text-balance text-base font-semibold"></h2>
<p className="mt-1.5 text-pretty text-sm leading-6 text-muted-foreground">
Pi
</p>
</div>
<CodingWelcomeHero
headingLevel="h2"
className="min-h-[18rem] flex-1 px-6 pb-12 pt-6 sm:pb-16"
/>
)}
</div>
</div>

View File

@@ -0,0 +1,29 @@
import makeloreMark from '@/assets/logo.svg';
import { cn } from '@/lib/utils';
export function CodingWelcomeHero({
className,
headingLevel = 'h1',
}: {
className?: string;
headingLevel?: 'h1' | 'h2';
}) {
const Heading = headingLevel;
return (
<div
data-testid="coding-welcome-hero"
className={cn('flex flex-col items-center justify-center text-center', className)}
>
<img
src={makeloreMark}
alt="麦洛 M 标识"
className="h-auto w-12 select-none sm:w-[3.25rem]"
draggable={false}
/>
<Heading className="mt-6 max-w-2xl text-balance text-[clamp(1.65rem,2.45vw,2.1rem)] font-medium leading-[1.28] tracking-[-0.04em] text-foreground">
</Heading>
</div>
);
}

View File

@@ -1,5 +1,9 @@
import { CodingChatPanel } from './CodingChatPanel';
import { useNavigate } from 'react-router-dom';
import {
requestCodingProjectCreation,
requestCodingProjectOpen,
} from '@/lib/coding-project-entry';
function getNavigationDraft(state: unknown): string | undefined {
if (!state || typeof state !== 'object') return undefined;
@@ -24,6 +28,8 @@ export function Chat() {
<div data-testid="chat-operation-page" className="flex h-[calc(100%_+_3rem)] min-h-0 -m-6 overflow-hidden bg-background">
<CodingChatPanel
navigationDraft={navigationDraft}
onCreateProject={requestCodingProjectCreation}
onOpenProject={requestCodingProjectOpen}
onOpenProjectSettings={() => navigate('/project-config')}
/>
</div>