fix: refine coding project landing actions

This commit is contained in:
inman
2026-09-06 15:45:22 +08:00
parent de8ce283dd
commit faf751be5e
10 changed files with 307 additions and 117 deletions

View File

@@ -31,7 +31,10 @@ import {
import { getAiModuleForPath } from '@/lib/ai-modules';
import { invokeIpc } from '@/lib/api-client';
import { getAuthUserDisplayName } from '@/lib/auth-user-display';
import { subscribeCodingProjectCreationRequest } from '@/lib/coding-project-entry';
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';
@@ -431,11 +434,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 ?? '项目配置无效');
@@ -445,16 +448,16 @@ export function Sidebar({ workspaceLayout = false, sidebarPeekOpen = false, onSi
navigate(result.config.initialized
? '/chat'
: projectConfigPath);
};
}, [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 () => {
@@ -495,9 +498,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

@@ -1,4 +1,5 @@
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;
@@ -10,3 +11,22 @@ export function subscribeCodingProjectCreationRequest(listener: () => void): ()
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,11 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
ArrowUp,
ChevronRight,
CircleAlert,
FolderKanban,
Plus,
RefreshCw,
Settings2,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
@@ -36,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';
@@ -49,6 +49,7 @@ import { createLocalConversationSnapshot } from './coding-chat-snapshot';
export interface CodingChatPanelProps {
navigationDraft?: string;
onCreateProject?(): void;
onOpenProject?(projectId: string): void;
onOpenProjectSettings?(): void;
}
@@ -112,11 +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);
@@ -604,7 +618,7 @@ export function CodingChatPanel({
>
<CodingWelcomeHero className="min-h-[18rem] flex-1 pb-8 pt-6 sm:pb-12" />
<div className="mx-auto w-full max-w-[46rem] shrink-0">
<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" />
@@ -621,64 +635,60 @@ export function CodingChatPanel({
</div>
) : null}
<div className="overflow-hidden rounded-[1.35rem] bg-surface-subtle shadow-[0_12px_36px_rgba(35,43,56,0.07)]" data-testid="coding-project-starter">
<button
type="button"
className="focus-ring flex min-h-10 w-full items-center gap-2.5 px-4 py-2 text-left text-xs font-medium text-muted-foreground transition-colors duration-150 hover:bg-surface-tertiary hover:text-foreground disabled:cursor-default disabled:hover:bg-transparent disabled:hover:text-muted-foreground"
onClick={onCreateProject}
disabled={!onCreateProject}
>
<FolderKanban className="h-4 w-4 shrink-0" aria-hidden="true" />
<span className="font-semibold text-foreground"></span>
<span aria-hidden="true">·</span>
<span></span>
</button>
<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>
<div className="chat-composer-surface border p-3 sm:p-3.5">
<button
type="button"
data-testid="coding-start-project-button"
className="focus-ring block min-h-[4.5rem] w-full rounded-xl px-1 pb-4 pt-1 text-left text-[15px] leading-6 text-muted-foreground transition-colors duration-150 hover:text-foreground disabled:cursor-default disabled:hover:text-muted-foreground"
onClick={onCreateProject}
disabled={!onCreateProject}
>
</button>
<div className="flex min-h-8 items-center gap-1">
<Button
type="button"
variant="ghost"
className="h-8 rounded-lg px-2.5 text-xs font-medium"
onClick={onCreateProject}
disabled={!onCreateProject}
>
<Plus className="mr-1.5 h-4 w-4 text-brand" aria-hidden="true" />
</Button>
{onOpenProjectSettings ? (
<Button
type="button"
variant="ghost"
className="h-8 rounded-lg px-2.5 text-xs font-medium text-muted-foreground"
onClick={onOpenProjectSettings}
>
<Settings2 className="mr-1.5 h-4 w-4" aria-hidden="true" />
</Button>
) : null}
<Button
type="button"
className="ml-auto h-8 w-8 shrink-0 rounded-full bg-foreground p-0 text-background shadow-soft hover:bg-foreground/90"
aria-label="新建或打开项目"
onClick={onCreateProject}
disabled={!onCreateProject}
>
<ArrowUp className="h-4 w-4" aria-hidden="true" />
</Button>
{projects.length > 0 ? (
<section className="mt-7" aria-labelledby="coding-existing-projects-title">
<div className="mb-3 flex items-end justify-between gap-4 px-0.5">
<h2 id="coding-existing-projects-title" className="text-sm font-semibold tracking-[-0.01em]">
</h2>
<p className="text-xs text-muted-foreground"></p>
</div>
</div>
</div>
<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

@@ -1,6 +1,9 @@
import { CodingChatPanel } from './CodingChatPanel';
import { useNavigate } from 'react-router-dom';
import { requestCodingProjectCreation } from '@/lib/coding-project-entry';
import {
requestCodingProjectCreation,
requestCodingProjectOpen,
} from '@/lib/coding-project-entry';
function getNavigationDraft(state: unknown): string | undefined {
if (!state || typeof state !== 'object') return undefined;
@@ -26,6 +29,7 @@ export function Chat() {
<CodingChatPanel
navigationDraft={navigationDraft}
onCreateProject={requestCodingProjectCreation}
onOpenProject={requestCodingProjectOpen}
onOpenProjectSettings={() => navigate('/project-config')}
/>
</div>