feat: include approved Code sidebar actions in combined trial source
This commit is contained in:
1 parent
16b10c44f1
commit
18e4be66da
12 files changed
+596
-61
No files matched your search
@@ -0,0 +1,57 @@
|
||||
# Task: Enable requested Code sidebar changes in current temporary app
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20261013-trial-sidebar-update-8e37a2c1
|
||||
- Mode: Feature
|
||||
- Branch: codex/20261013-trial-sidebar-update-8e37a2c1-trial-sidebar-update
|
||||
- Worktree: /Users/chillishark/Makelore 麦洛/.codex-worktrees/makelore/20261013-trial-sidebar-update-8e37a2c1
|
||||
- Base commit: 16b10c44f1b846f33dbf28c0050f15a68fdae53b
|
||||
- Owner: codex
|
||||
- Status: In Progress
|
||||
|
||||
## Scope
|
||||
|
||||
- Enable the requested Coding前端-页面修改2 thread's latest Code sidebar correction in the current temporary app alongside the delivered TOOL/SKILL and left-card knowledge upload correction. Preserve earlier address-bar/refresh-icon/Work-tab introduction layout changes already included in the baseline.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- User explicitly requested updating the current temporary app with this thread's work. Apply only committed Renderer/UI/test/README changes from 20e2e5996079fea29296c604f96acc268f8c65ce; do not merge or push main, clean worktrees, deploy cloud patches, restart Main, save/apply live Agent configuration, mutate user conversations, record audio or call models. Preserve current login, saved configuration, draft/input and ongoing work; refresh only after actual native saved/idle evidence.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
- Task ID/mode/branch/worktree/base: Identity above exactly matches official check/start/status. New managed feature worktree from the previous task's docs-only delivery commit16b10c4, with effective Renderer ad223603 and actual Main020b366/PID12660/Host58003.
|
||||
- Other active local tasks: all50 exact peer owner/task-record paths were read only by delegated audit. Source sidebar task ready; recent Agent selectors, knowledge layout, channel, composer and consolidation tasks are compatible. An old child-language placeholder has unknown scope but no concrete dependency here. No peer or canonical edits.
|
||||
- Read: AGENTS, entry/read-before-planning, memory-index, own record, positioning, historical integrated current-state, decision-index, relevant system-overview, community-collaboration, project conversations/archive domain rules, success criteria, evidence/reflection/commitment indexes and source sidebar record. Read requested thread's latest14 turns, distinguishing source user directions from screenshots and old deployment instructions.
|
||||
- Project goal/current focus: creative Electron client with project-scoped Code state and Main-owned auth/runtime/API. Shared integrated docs are historical; current runtime pointer plus immutable snapshot identify the actual temporary baseline.
|
||||
- Scope/constraints/decisions: sidebar hierarchy is already user-approved; archive remains recoverable and task/conversation identity stays Main-owned. TOOL/SKILL and left upload remain as just explicitly corrected. No API/schema, provider, runtime, account or cloud deployment change is needed.
|
||||
- Evidence/reflection/commitments: source79 units and two focused Electron scenarios passed; historical broad navigation/titlebar assertions failed before this source and are not a delivery proof. Feature-mode docs are task-scoped; source records remain read only. Real provider/cloud deployment acceptance remains separate.
|
||||
- Likely files: Sidebar.tsx, MainLayout.tsx, sidebar-peek.ts, CodingProjectConversations.tsx, classroom-workspace.css plus exact source README/test hunks. All five source product files in20e2e59^ are byte-identical to this task base, so the source patch needs no product conflict resolution. d12d214 Work-tab intro fix and d787267 address-bar fix are already ancestors.
|
||||
- Unknowns/conflicts: broad historical test failures retained as limits; none blocks the targeted change. Planning Gate: Passed before implementation.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Apply the exact source feature hunks excluding its peer task record; verify no Main/shared/resources/package/lock/build-config delta and preserve current corrections.
|
||||
2. Run focused source unit/UI scenarios, current Agent regression assertions, typecheck, lint and default full build; review resulting immutable source.
|
||||
3. Prepare a file-only Renderer overlay over identical current Main bytes. Check live native UI saved/idle state, activate files, refresh through CUA, verify Code sidebar and retained Agent controls; finalize runtime metadata only after actual observation.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Applied exact source20e2e59 product/README/test hunks, excluding its peer task record. New conversation is labeled and left aligned; project-card overflow owns archived/recent lists and project settings, while per-conversation rename/recoverable archive/restore and task state remain unchanged. Open project menus retain sidebar preview through the independent menu peek source.
|
||||
- Independent combined-tree review passed: five changed product files exactly match source; all three TOOL/SKILL/knowledge product files stay identical toad223603; Main/shared/resources/package/lock/build config delta versus actual Main020b366 is empty. Prior Work-tab welcome hiding, address bar placement and icon refresh remain included.
|
||||
- Default compiled build is ready; immutable snapshot and saved/idle native refresh/verification remain to deliver before runtime metadata finalization.
|
||||
|
||||
## Verification
|
||||
|
||||
- Typecheck, changed-file ESLint, default full build:vite with NIANCODE_RENDERER_ONLY unset, git diff --check and task-aware document drift passed.
|
||||
- 136 unit cases passed (source79 plus existing Agent configuration/knowledge57); ten test files. The two focused isolated Electron scenarios both passed (3.4s and3.9s, total8.3s): conversation rename/archive/restore without selecting/stopping target; project-targeting, peek-menu behavior, keyboard access and 128/171/256px left alignment/full label/no overflow.
|
||||
- Logs: /tmp/makelore-sidebar8e37-{type,lint,unit,build,electron}.log; Electron screenshots/artifacts: /tmp/makelore-sidebar8e37-electron. Tests use isolated temporary state and synthetic host, not real user projects/model/cloud mutations.
|
||||
- Source's broader historical titlebar/equal-height assertions are unrelated existing mismatches and were not rerun or counted as passed. No full-suite or real-provider claim.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- None recorded.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None recorded.
|
||||
@@ -150,7 +150,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
||||
|
||||
### 项目智能体与对话
|
||||
|
||||
- 对话行的“…”菜单支持重命名和归档,项目下的归档入口可查看并恢复原对话。归档保留历史、附件和 Pi Session,不中止正在执行的任务;已归档对话仍可查看、处理审批或中止,恢复后才能继续发送或创建分支。全部归档后显示空列表,不自动创建空对话。
|
||||
- 项目下仅保留与项目卡片左边框对齐的“+ 新对话”;项目卡片右侧的“…”菜单提供“已归档对话”和“项目设置”。对话行的“…”菜单支持重命名和归档,归档列表可查看并恢复原对话,也可返回最近对话;新建或恢复后回到最近对话列表。归档保留历史、附件和 Pi Session,不中止正在执行的任务;已归档对话仍可查看、处理审批或中止,恢复后才能继续发送或创建分支。全部归档后显示空列表,不自动创建空对话。
|
||||
- 新对话使用第一条实际用户消息的首行作为标题,合并空白并截取最多 32 个字符;纯图片消息使用“附件对话”,命令和空消息不参与命名。不额外调用模型,不随后续消息改名。历史标题保持原样,手动重命名优先,标题最多 200 个字符;仅改标题不改变最近对话排序。Main 在本地元数据落盘后通过独立的 metadata-changed 事件通知列表,事件重连也会刷新列表。
|
||||
|
||||
- 新项目自动建立一个内部默认编程智能体,`defaultAgentId` 固定其身份;创建项目不再要求创建或选择智能体。有可用的全局默认模型时复制精确账号与模型,没有时保留待选择状态。旧项目优先保留原默认或置顶/首个可用智能体,保留所有历史绑定;全部停用时新增默认配置,不复活旧配置。
|
||||
|
||||
@@ -25,6 +25,7 @@ export function MainLayout() {
|
||||
focus: false,
|
||||
sidebar: false,
|
||||
titlebar: false,
|
||||
menu: false,
|
||||
});
|
||||
const activeModule = getAiModuleForPath(location.pathname);
|
||||
const isPaintingModule = activeModule === 'painting';
|
||||
@@ -60,7 +61,7 @@ export function MainLayout() {
|
||||
}, [sidebarCollapsed, isCloudAgents]);
|
||||
|
||||
useEffect(() => {
|
||||
sidebarPeekSourcesRef.current = { toggle: false, focus: false, sidebar: false, titlebar: false };
|
||||
sidebarPeekSourcesRef.current = { toggle: false, focus: false, sidebar: false, titlebar: false, menu: false };
|
||||
// This transient state mirrors the external persisted sidebar preference;
|
||||
// reset it on preference changes and both edges of the Agents boundary so
|
||||
// a previous module's preview or timer cannot reopen the manual-only sidebar.
|
||||
|
||||
@@ -4,14 +4,17 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import * as DropdownMenu from '@radix-ui/react-dropdown-menu';
|
||||
import {
|
||||
Archive,
|
||||
Bot,
|
||||
BotMessageSquare,
|
||||
Cpu,
|
||||
ChevronRight,
|
||||
FolderKanban,
|
||||
Gamepad2,
|
||||
LogOut,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Plus,
|
||||
Settings as SettingsIcon,
|
||||
UserCircle2,
|
||||
@@ -51,7 +54,7 @@ import { getAccountInitial } from '@/components/profile/user-avatar-utils';
|
||||
import { UserProfileDialog } from '@/components/profile/UserProfileDialog';
|
||||
import { useSettingsStore } from '@/stores/settings';
|
||||
import { useAuthStore } from '@/stores/auth';
|
||||
import { useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { codingWorkspaceStore, useCodingWorkspaceStore } from '@/stores/coding-workspace';
|
||||
import { useProviderStore } from '@/stores/providers';
|
||||
import type { CodingProjectSummary } from '@/types/coding-project';
|
||||
import { useProjectConfigStore } from '@/stores/project-config';
|
||||
@@ -114,6 +117,8 @@ export function Sidebar({
|
||||
} = useCurrentUserProfile();
|
||||
const refreshProviderSnapshot = useProviderStore((state) => state.refreshProviderSnapshot);
|
||||
const [projectsOpen, setProjectsOpen] = useState(true);
|
||||
const [archivedProjectId, setArchivedProjectId] = useState<string | null>(null);
|
||||
const [projectMenuOpen, setProjectMenuOpen] = useState(false);
|
||||
const [accountMenuOpen, setAccountMenuOpen] = useState(false);
|
||||
const [profileDialogOpen, setProfileDialogOpen] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
@@ -339,6 +344,24 @@ export function Sidebar({
|
||||
await readAndEnterProject(project);
|
||||
}, [readAndEnterProject]);
|
||||
|
||||
const openProjectAction = async (project: CodingProjectSummary, action: 'settings' | 'archived' | 'recent') => {
|
||||
const opening = openProject(project);
|
||||
const intent = projectEntryIntentRef.current;
|
||||
await opening;
|
||||
if (intent !== projectEntryIntentRef.current) return;
|
||||
if (codingWorkspaceStore.getState().activeProjectId !== project.id) return;
|
||||
if (action === 'settings') {
|
||||
navigate('/project-config');
|
||||
} else {
|
||||
setArchivedProjectId(action === 'archived' ? project.id : null);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
onSidebarPeekChange?.(projectMenuOpen, 'menu');
|
||||
return () => onSidebarPeekChange?.(false, 'menu');
|
||||
}, [projectMenuOpen, onSidebarPeekChange]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isProgrammingModule) return undefined;
|
||||
return subscribeCodingProjectOpenRequest((projectId) => {
|
||||
@@ -476,26 +499,71 @@ export function Sidebar({
|
||||
|
||||
{visibleProjects.map((project) => {
|
||||
const active = activeProject?.id === project.id;
|
||||
const archived = active && archivedProjectId === project.id;
|
||||
return (
|
||||
<div key={project.id}><button
|
||||
type="button"
|
||||
data-testid={`sidebar-course-project-${project.id}`}
|
||||
aria-label={`进入项目 ${project.name}`}
|
||||
title={project.name}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={() => void openProject(project)}
|
||||
className={cn(
|
||||
'classroom-project-card motion-press group/project flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1',
|
||||
active
|
||||
? 'bg-transparent text-foreground'
|
||||
: 'bg-transparent text-foreground hover:bg-surface-tertiary',
|
||||
<div key={project.id}>
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`sidebar-course-project-${project.id}`}
|
||||
aria-label={`进入项目 ${project.name}`}
|
||||
title={project.name}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
onClick={() => void openProject(project)}
|
||||
className={cn(
|
||||
'classroom-project-card motion-press group/project flex w-full items-center gap-2 rounded-xl py-2 pl-3 pr-10 text-left transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 focus-visible:ring-offset-1',
|
||||
active
|
||||
? 'bg-transparent text-foreground'
|
||||
: 'bg-transparent text-foreground hover:bg-surface-tertiary',
|
||||
)}
|
||||
>
|
||||
{workspaceLayout ? <span className="classroom-project-icon" data-tone={['orange', 'green', 'blue'][Array.from(project.id).reduce((sum, char) => sum + char.charCodeAt(0), 0) % 3]}><Gamepad2 aria-hidden="true" /></span> : <Gamepad2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{project.name}</span>
|
||||
</button>
|
||||
<DropdownMenu.Root onOpenChange={setProjectMenuOpen}>
|
||||
<DropdownMenu.Trigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`项目操作:${project.name}`}
|
||||
title="项目操作"
|
||||
className="classroom-project-actions absolute right-1 top-1/2 flex h-8 w-8 -translate-y-1/2 items-center justify-center rounded-lg text-muted-foreground hover:bg-surface-tertiary hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35 data-[state=open]:bg-surface-tertiary"
|
||||
>
|
||||
<MoreHorizontal aria-hidden="true" className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Portal>
|
||||
<DropdownMenu.Content
|
||||
className="z-[400] min-w-40 rounded-lg border bg-popover p-1 text-sm shadow-md"
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center rounded p-2 outline-none focus:bg-accent"
|
||||
onSelect={() => void openProjectAction(project, archived ? 'recent' : 'archived')}
|
||||
>
|
||||
{archived ? <MessageSquare aria-hidden="true" className="mr-2 h-3.5 w-3.5" /> : <Archive aria-hidden="true" className="mr-2 h-3.5 w-3.5" />}
|
||||
{archived ? '最近对话' : '已归档对话'}
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
className="flex items-center rounded p-2 outline-none focus:bg-accent"
|
||||
onSelect={() => void openProjectAction(project, 'settings')}
|
||||
>
|
||||
<SettingsIcon aria-hidden="true" className="mr-2 h-3.5 w-3.5" />
|
||||
项目设置
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Portal>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
{active && (
|
||||
<CodingProjectConversations
|
||||
key={project.id}
|
||||
projectId={project.id}
|
||||
compact={workspaceLayout}
|
||||
archived={archived}
|
||||
onArchivedChange={(value) => setArchivedProjectId(value ? project.id : null)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
{workspaceLayout ? <span className="classroom-project-icon" data-tone={['orange', 'green', 'blue'][Array.from(project.id).reduce((sum, char) => sum + char.charCodeAt(0), 0) % 3]}><Gamepad2 aria-hidden="true" /></span> : <Gamepad2 className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />}
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{project.name}</span>
|
||||
<ChevronRight aria-hidden="true" className="h-3.5 w-3.5 shrink-0 -translate-x-0.5 text-muted-foreground opacity-50 transition-[opacity,transform,color] duration-200 ease-out group-hover/project:translate-x-0 group-hover/project:text-foreground group-hover/project:opacity-100 group-focus-visible/project:translate-x-0 group-focus-visible/project:text-foreground group-focus-visible/project:opacity-100" />
|
||||
</button>
|
||||
{active && <CodingProjectConversations key={project.id} projectId={project.id} compact={workspaceLayout} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export type SidebarPeekSource = 'toggle' | 'focus' | 'sidebar' | 'titlebar';
|
||||
export type SidebarPeekSource = 'toggle' | 'focus' | 'sidebar' | 'titlebar' | 'menu';
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
LoaderCircle,
|
||||
MessageSquarePlus,
|
||||
Plus,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Settings2,
|
||||
@@ -18,13 +18,22 @@ import type { CodingConversationMetadata } from '@/types/coding-project';
|
||||
import { CodingConversationRenameDialog } from './CodingConversationRenameDialog';
|
||||
import { createLocalConversationSnapshot } from './coding-chat-snapshot';
|
||||
|
||||
export function CodingProjectConversations({ projectId, compact = false }: { projectId: string; compact?: boolean }) {
|
||||
export function CodingProjectConversations({
|
||||
projectId,
|
||||
compact = false,
|
||||
archived = false,
|
||||
onArchivedChange,
|
||||
}: {
|
||||
projectId: string;
|
||||
compact?: boolean;
|
||||
archived?: boolean;
|
||||
onArchivedChange?: (archived: boolean) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const conversations = useCodingWorkspaceStore((state) => state.conversations);
|
||||
const activeProjectId = useCodingWorkspaceStore((state) => state.activeProjectId);
|
||||
const selected = useCodingConversationStore((state) => state.selectedConversationId);
|
||||
const summaries = useCodingConversationStore((state) => state.summariesByConversationId);
|
||||
const [archived, setArchived] = useState(false);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [rename, setRename] = useState<CodingConversationMetadata | null>(null);
|
||||
const selectedConversation = conversations.find((item) => item.id === selected);
|
||||
@@ -36,9 +45,9 @@ export function CodingProjectConversations({ projectId, compact = false }: { pro
|
||||
previous?.archivedAt &&
|
||||
!selectedConversation?.archivedAt
|
||||
)
|
||||
setArchived(false);
|
||||
onArchivedChange?.(false);
|
||||
previousSelection.current = selectedConversation;
|
||||
}, [selectedConversation]);
|
||||
}, [selectedConversation, onArchivedChange]);
|
||||
if (activeProjectId !== projectId) return null;
|
||||
const act = async (key: string, operation: () => Promise<unknown>) => {
|
||||
setBusy(key);
|
||||
@@ -76,34 +85,34 @@ export function CodingProjectConversations({ projectId, compact = false }: { pro
|
||||
aria-label="新建对话"
|
||||
title="新建对话"
|
||||
disabled={!!busy}
|
||||
className={`flex flex-1 items-center gap-1.5 rounded py-2 text-xs hover:bg-surface-tertiary ${compact ? "justify-center px-1" : "px-2"}`}
|
||||
className={`flex shrink-0 items-center gap-1.5 rounded py-2 pr-2 text-xs hover:bg-surface-tertiary ${compact ? "pl-0" : "pl-2"}`}
|
||||
onClick={() =>
|
||||
void act('new', async () => {
|
||||
const workspace = codingWorkspaceStore.getState();
|
||||
const conversation = await workspace.createConversation();
|
||||
if (codingWorkspaceStore.getState().activeProjectId === projectId) {
|
||||
setArchived(false);
|
||||
onArchivedChange?.(false);
|
||||
await select(conversation);
|
||||
}
|
||||
})
|
||||
}
|
||||
>
|
||||
<MessageSquarePlus className="h-3.5 w-3.5" />
|
||||
{!compact && '新对话'}
|
||||
</button>
|
||||
<button
|
||||
aria-label={archived ? '返回最近对话' : '查看归档对话'}
|
||||
title={archived ? '返回最近对话' : '查看归档对话'}
|
||||
onClick={() => setArchived(!archived)}
|
||||
className="p-2"
|
||||
>
|
||||
<Archive className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button aria-label="项目设置" title="项目设置" onClick={() => navigate('/project-config')} className="p-2">
|
||||
<Settings2 className="h-3.5 w-3.5" />
|
||||
<Plus aria-hidden="true" className="h-3.5 w-3.5 shrink-0" />
|
||||
新对话
|
||||
</button>
|
||||
</div>
|
||||
{archived && <p className="px-2 py-1 text-xs text-muted-foreground">已归档</p>}
|
||||
{archived && (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1 text-xs text-muted-foreground">
|
||||
<span>已归档对话</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onArchivedChange?.(false)}
|
||||
className="rounded py-1 text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/35"
|
||||
>
|
||||
返回最近对话
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{conversations
|
||||
.filter((item) => Boolean(item.archivedAt) === archived)
|
||||
.sort((a, b) =>
|
||||
@@ -175,6 +184,11 @@ export function CodingProjectConversations({ projectId, compact = false }: { pro
|
||||
await codingWorkspaceStore
|
||||
.getState()
|
||||
.patchConversation(item.id, { archived: !item.archivedAt });
|
||||
if (
|
||||
item.archivedAt &&
|
||||
codingWorkspaceStore.getState().activeProjectId === projectId
|
||||
)
|
||||
onArchivedChange?.(false);
|
||||
if (
|
||||
!item.archivedAt &&
|
||||
codingConversationStore.getState().selectedConversationId === item.id
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-projects { margin-top: 8px; }
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-projects-heading { padding: 6px 3px; color: #64758a; }
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-projects-heading > svg { display: none; }
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card { min-height: 56px; gap: 9px; padding: 8px; border: 1px solid transparent; border-radius: 13px; }
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card { min-height: 56px; gap: 9px; padding: 8px 40px 8px 8px; border: 1px solid transparent; border-radius: 13px; }
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card:hover { background: #fff; }
|
||||
[data-testid="sidebar"][data-classroom="true"] .classroom-project-card[aria-current="page"] { background: #fff; border-color: #c9d9e9; box-shadow: 0 3px 0 #e2eaf3; }
|
||||
.classroom-project-icon { display: grid; place-items: center; flex-shrink: 0; width: 34px; height: 37px; border-radius: 10px; background: #ffead9; color: #a85026; }
|
||||
|
||||
@@ -393,7 +393,8 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
await sidebarResizer.dblclick();
|
||||
await expect(sidebarResizer).toHaveAttribute('aria-valuenow', '171');
|
||||
|
||||
await sidebar.getByRole('button', { name: '项目设置', exact: true }).click();
|
||||
await sidebar.getByRole('button', { name: '项目操作:故事绘本', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: '项目设置', exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/project-config$/);
|
||||
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
|
||||
await expect(sidebar.getByTestId('sidebar-module-switcher')).toHaveCount(0);
|
||||
@@ -404,7 +405,8 @@ test.describe('Makelore module navigation without setup flow', () => {
|
||||
await expect(page).toHaveURL(/\/chat$/);
|
||||
await expect(moduleReturn).toBeVisible();
|
||||
|
||||
await sidebar.getByRole('button', { name: '项目设置', exact: true }).click();
|
||||
await sidebar.getByRole('button', { name: '项目操作:故事绘本', exact: true }).click();
|
||||
await page.getByRole('menuitem', { name: '项目设置', exact: true }).click();
|
||||
await page.getByTestId('resource-card-plugins').click();
|
||||
await expect(page).toHaveURL(/\/project-config\/plugins\?/);
|
||||
const plugins = page.getByTestId('project-plugins-sheet');
|
||||
|
||||
@@ -632,7 +632,8 @@ async function installCodingFirstChatHost(
|
||||
return respond({ project: activeProject });
|
||||
}
|
||||
if (multipleProjects && path === `/api/coding/projects/config?projectId=${otherProject.id}`)
|
||||
return respond({ snapshot: { project: otherProject, config, knowledgeFiles: [] } });
|
||||
return respond({ snapshot: { project: otherProject,
|
||||
config: { ...config, defaultAgentId: agent.id, agents: [{ ...agent, prompt: 'Settings for Another project' }] }, knowledgeFiles: [] } });
|
||||
if (multipleProjects && path === `/api/coding/projects/conversations?projectId=${otherProject.id}`)
|
||||
return respond({ conversations: [{ ...secondConversation, id: 'conversation-other-project', title: 'Other project conversation' }] });
|
||||
if (multipleProjects && path === '/api/coding/conversations/conversation-other-project/snapshot')
|
||||
@@ -995,7 +996,10 @@ test('conversation menus rename, archive and restore without selecting or stoppi
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
const sidebar = page.getByTestId('project-conversations');
|
||||
const header = page.getByTestId('coding-conversation-header');
|
||||
const projectMenu = page.getByRole('button', { name: '项目操作:PI first chat', exact: true });
|
||||
await expect(header).toContainText('新对话');
|
||||
await expect(sidebar.getByRole('button', { name: '查看归档对话' })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole('button', { name: '项目设置', exact: true })).toHaveCount(0);
|
||||
await sidebar.getByRole('button', { name: '对话操作:Second Conversation' }).focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('menuitem', { name: '重命名', exact: true }).click();
|
||||
@@ -1009,20 +1013,148 @@ test('conversation menus rename, archive and restore without selecting or stoppi
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('menuitem', { name: '归档', exact: true }).click();
|
||||
await expect(sidebar.getByRole('button', { name: '重命名后的会话', exact: true })).toHaveCount(0);
|
||||
await sidebar.getByRole('button', { name: '查看归档对话' }).click();
|
||||
await projectMenu.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await page.getByRole('menuitem', { name: '已归档对话', exact: true }).click();
|
||||
await expect(sidebar.getByText('已归档对话', { exact: true })).toBeVisible();
|
||||
await expect(sidebar.getByRole('button', { name: '返回最近对话', exact: true })).toBeVisible();
|
||||
await expect(header).toContainText('新对话');
|
||||
await projectMenu.click();
|
||||
await expect(page.getByRole('menuitem', { name: '最近对话', exact: true })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await sidebar.getByRole('button', { name: '重命名后的会话', exact: true }).click();
|
||||
await expect(page.getByRole('button', { name: '恢复对话', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: '发送', exact: true })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '恢复对话', exact: true }).click();
|
||||
await expect(header).toContainText('重命名后的会话');
|
||||
await expect(sidebar.getByRole('button', { name: '查看归档对话' })).toBeVisible();
|
||||
await expect(sidebar.getByRole('button', { name: '返回最近对话', exact: true })).toHaveCount(0);
|
||||
await expect(sidebar.getByRole('button', { name: '重命名后的会话', exact: true })).toBeVisible();
|
||||
await projectMenu.click();
|
||||
await page.getByRole('menuitem', { name: '已归档对话', exact: true }).click();
|
||||
await sidebar.getByRole('button', { name: '返回最近对话', exact: true }).click();
|
||||
await expect(sidebar.getByRole('button', { name: '重命名后的会话', exact: true })).toBeVisible();
|
||||
const requests = (await readState(electronApp)).captured;
|
||||
expect(requests.some((request) => request.path.endsWith('/abort'))).toBe(false);
|
||||
expect(requests.filter((request) => request.method === 'PATCH' && request.path.endsWith('/conversation-pi-second'))
|
||||
.map((request) => request.body)).toEqual([
|
||||
{ title: '重命名后的会话' }, { archived: true }, { archived: false },
|
||||
]);
|
||||
await page.screenshot({ path: 'test-results/coding-session-controls.png' });
|
||||
await page.screenshot({ path: test.info().outputPath('coding-session-controls.png') });
|
||||
} finally { await releaseSnapshot(electronApp); }
|
||||
});
|
||||
|
||||
test('project menus target their project and keep new conversations aligned at every sidebar width', async ({ launchElectronApp }) => {
|
||||
const electronApp = await launchElectronApp({ skipSetup: true });
|
||||
let page = await getStableWindow(electronApp);
|
||||
const connection = await page.evaluate(async () => ({
|
||||
token: await window.electron.ipcRenderer.invoke('hostapi:token') as string,
|
||||
baseUrl: await window.electron.ipcRenderer.invoke('hostapi:base-url') as string,
|
||||
}));
|
||||
await installCodingFirstChatHost(electronApp, connection, true, false, false, undefined, true);
|
||||
await settleSnapshot(electronApp);
|
||||
await disableCodingEventSource(page);
|
||||
try {
|
||||
await page.reload();
|
||||
page = await getStableWindow(electronApp);
|
||||
await page.getByTestId('ai-module-option-programming').click();
|
||||
await page.evaluate(() => { window.location.hash = '/chat'; });
|
||||
const rail = page.getByTestId('sidebar');
|
||||
const conversations = page.getByTestId('project-conversations');
|
||||
const create = conversations.getByRole('button', { name: '新建对话', exact: true });
|
||||
const primaryProject = page.getByTestId('sidebar-course-project-project-pi-first-chat');
|
||||
const otherProject = page.getByTestId('sidebar-course-project-project-pi-other');
|
||||
const primaryMenu = page.getByRole('button', { name: '项目操作:PI first chat', exact: true });
|
||||
const otherMenu = page.getByRole('button', { name: '项目操作:Another project', exact: true });
|
||||
const resizer = page.getByRole('separator', { name: '调整左侧功能栏宽度' });
|
||||
await expect(primaryProject).toHaveAttribute('aria-current', 'page');
|
||||
await expect(create).toHaveText('新对话');
|
||||
await expect(conversations.locator('.classroom-conversation-actions').getByRole('button')).toHaveCount(1);
|
||||
expect(await otherMenu.evaluate(element => element.parentElement?.querySelector('button')?.contains(element))).toBe(false);
|
||||
|
||||
for (const width of [171, 128, 256]) {
|
||||
if (width !== 171) await resizer.press(width === 128 ? 'Home' : 'End');
|
||||
await expect(rail).toHaveCSS('width', `${width}px`);
|
||||
await expect(create).toBeInViewport({ ratio: 1 });
|
||||
const [iconBounds, cardBounds] = await Promise.all([create.locator('svg').boundingBox(), primaryProject.boundingBox()]);
|
||||
expect(Math.abs(iconBounds!.x - cardBounds!.x)).toBeLessThanOrEqual(1);
|
||||
expect(await create.evaluate(element => {
|
||||
const text = Array.from(element.childNodes).find(node => node.nodeType === Node.TEXT_NODE && node.textContent?.includes('新对话'));
|
||||
if (!text) return false;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(text);
|
||||
const label = range.getBoundingClientRect();
|
||||
const button = element.getBoundingClientRect();
|
||||
return label.width > 0 && label.left >= button.left && label.right <= button.right && label.bottom <= button.bottom;
|
||||
})).toBe(true);
|
||||
for (const element of [rail, conversations, create, primaryProject]) {
|
||||
expect(await element.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true);
|
||||
}
|
||||
await expect(primaryMenu).toBeInViewport({ ratio: 1 });
|
||||
await page.screenshot({ path: test.info().outputPath(`sidebar-actions-${width}.png`) });
|
||||
}
|
||||
|
||||
const sidebarToggle = page.getByTestId('titlebar-sidebar-toggle');
|
||||
await sidebarToggle.click();
|
||||
await page.mouse.move(800, 500);
|
||||
await expect(rail).toHaveAttribute('aria-hidden', 'true');
|
||||
await sidebarToggle.hover();
|
||||
await expect(rail).toHaveAttribute('aria-hidden', 'false');
|
||||
await primaryMenu.click();
|
||||
const archivedMenuItem = page.getByRole('menuitem', { name: '已归档对话', exact: true });
|
||||
await expect(archivedMenuItem).toBeVisible();
|
||||
await page.mouse.move(800, 500);
|
||||
// Stay outside the rail longer than its 180ms peek-close delay. An open
|
||||
// portal menu must keep the preview available and remain above it.
|
||||
await page.waitForTimeout(350);
|
||||
// Radix temporarily marks background content aria-hidden while its modal
|
||||
// menu is open, so measure the rail's visual state here instead.
|
||||
await expect(rail).toHaveCSS('width', '256px');
|
||||
await expect(rail).toHaveCSS('opacity', '1');
|
||||
await expect(archivedMenuItem).toBeVisible();
|
||||
await archivedMenuItem.click({ trial: true });
|
||||
await page.screenshot({ path: test.info().outputPath('sidebar-project-menu-peek.png') });
|
||||
await archivedMenuItem.click();
|
||||
await page.mouse.move(800, 500);
|
||||
await expect(rail).toHaveAttribute('aria-hidden', 'true');
|
||||
await sidebarToggle.hover();
|
||||
await conversations.getByRole('button', { name: '返回最近对话', exact: true }).click();
|
||||
await sidebarToggle.click();
|
||||
await expect(rail).toHaveCSS('width', '256px');
|
||||
await expect(resizer).toBeVisible();
|
||||
|
||||
const activationRequests = () => readState(electronApp).then(state => state.captured.filter(request =>
|
||||
request.path === '/api/coding/projects/active' && request.method === 'POST'));
|
||||
const activationsBeforeMenu = await activationRequests();
|
||||
await otherMenu.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(page.getByRole('menuitem', { name: '已归档对话', exact: true })).toBeVisible();
|
||||
await expect(page.getByRole('menuitem', { name: '项目设置', exact: true })).toBeVisible();
|
||||
await expect(primaryProject).toHaveAttribute('aria-current', 'page');
|
||||
await expect(otherProject).not.toHaveAttribute('aria-current', 'page');
|
||||
await expect(page.getByTestId('coding-conversation-header')).toContainText('新对话');
|
||||
expect(await activationRequests()).toEqual(activationsBeforeMenu);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(otherMenu).toBeFocused();
|
||||
await otherMenu.click();
|
||||
await page.getByRole('menuitem', { name: '项目设置', exact: true }).click();
|
||||
await expect(page.getByTestId('project-configuration-page')).toBeVisible();
|
||||
await expect(page.getByRole('textbox', { name: '项目提示词', exact: true })).toHaveValue('Settings for Another project');
|
||||
await expect(otherProject).toHaveAttribute('aria-current', 'page');
|
||||
expect((await activationRequests()).at(-1)?.body).toEqual({ projectId: 'project-pi-other' });
|
||||
|
||||
// The archive action also validates and activates an inactive project.
|
||||
await primaryMenu.click();
|
||||
await page.getByRole('menuitem', { name: '已归档对话', exact: true }).click();
|
||||
await expect(page.getByTestId('coding-conversation-header')).toBeVisible();
|
||||
await expect(primaryProject).toHaveAttribute('aria-current', 'page');
|
||||
await expect(conversations.getByText('已归档对话', { exact: true })).toBeVisible();
|
||||
expect((await activationRequests()).at(-1)?.body).toEqual({ projectId: 'project-pi-first-chat' });
|
||||
await primaryMenu.click();
|
||||
await page.getByRole('menuitem', { name: '最近对话', exact: true }).click();
|
||||
await expect(conversations.getByRole('button', { name: '返回最近对话', exact: true })).toHaveCount(0);
|
||||
await expect(conversations.getByRole('button', { name: '新对话', exact: true })).toBeVisible();
|
||||
expect((await readState(electronApp)).captured.filter(request => request.method === 'POST'
|
||||
&& /\/(prompt|messages|abort)$/.test(request.path))).toHaveLength(0);
|
||||
} finally { await releaseSnapshot(electronApp); }
|
||||
});
|
||||
|
||||
@@ -1579,7 +1711,10 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
|
||||
)).toBe(false);
|
||||
await page.getByRole('tab', { name: '操作对话' }).click();
|
||||
await expect(page.getByTestId('agent-browser-panel')).toHaveCount(0);
|
||||
await expect(page.getByRole('button', { name: '项目设置' })).toHaveCount(1);
|
||||
await expect(page.getByRole('button', { name: '项目设置' })).toHaveCount(0);
|
||||
await page.getByRole('button', { name: '项目操作:PI first chat', exact: true }).click();
|
||||
await expect(page.getByRole('menuitem', { name: '项目设置', exact: true })).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.getByTestId('project-conversations')).toBeVisible();
|
||||
const activeProcess = page.getByTestId('coding-process-group');
|
||||
await expect(activeProcess).toHaveAttribute('open', '');
|
||||
|
||||
@@ -523,7 +523,15 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
run: { status: 'running', runId: 'run-1' } });
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const CodingChatPanel = (props: import('@/pages/Chat/CodingChatPanel').CodingChatPanelProps) => <><CodingProjectConversations projectId={project.id} /><CoreChatPanel {...props} /></>;
|
||||
const { useState } = await import('react');
|
||||
const CodingChatPanel = () => {
|
||||
const [archived, setArchived] = useState(false);
|
||||
return <>
|
||||
<button onClick={() => setArchived(true)}>打开归档列表</button>
|
||||
<CodingProjectConversations projectId={project.id} archived={archived} onArchivedChange={setArchived} />
|
||||
<CoreChatPanel />
|
||||
</>;
|
||||
};
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
render(<CodingChatPanel />);
|
||||
const menu = await screen.findByRole('button', { name: '对话操作:新对话' });
|
||||
@@ -533,7 +541,8 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
await screen.findByRole('heading', { name: '今天,你想创造什么?' });
|
||||
expect(projectApi.create).not.toHaveBeenCalled();
|
||||
expect(conversationApi.abort).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '查看归档对话' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开归档列表' }));
|
||||
expect(screen.getByText('已归档对话')).toBeVisible();
|
||||
fireEvent.click(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true }));
|
||||
await screen.findByText('此对话已归档,任务仍在运行。');
|
||||
expect(screen.queryByRole('button', { name: '发送', exact: true })).not.toBeInTheDocument();
|
||||
@@ -541,6 +550,92 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
await waitFor(() => expect(screen.queryByRole('button', { name: '恢复对话' })).not.toBeInTheDocument());
|
||||
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
|
||||
expect(projectApi.patch).toHaveBeenLastCalledWith(conversation.id, { archived: false });
|
||||
expect(screen.queryByText('已归档对话')).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument();
|
||||
expect(within(screen.getByTestId('project-conversations')).getByRole('button', { name: '新对话', exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
expect(conversationApi.abort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns to recent conversations when restoring an unselected archived conversation from its menu', async () => {
|
||||
const archivedConversation = {
|
||||
...conversation,
|
||||
id: 'archived-conversation',
|
||||
title: '归档中的对话',
|
||||
archivedAt: '2026-09-20T00:00:00Z',
|
||||
};
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([conversation, archivedConversation]);
|
||||
projectApi.patch.mockResolvedValue({ ...archivedConversation, archivedAt: null });
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, conversation));
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { useState } = await import('react');
|
||||
const CodingChatPanel = () => {
|
||||
const [archived, setArchived] = useState(true);
|
||||
return <>
|
||||
<CodingProjectConversations projectId={project.id} archived={archived} onArchivedChange={setArchived} />
|
||||
<CoreChatPanel />
|
||||
</>;
|
||||
};
|
||||
render(<CodingChatPanel />);
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id));
|
||||
const list = within(screen.getByTestId('project-conversations'));
|
||||
expect(list.getByText('已归档对话')).toBeVisible();
|
||||
expect(list.queryByRole('button', { name: conversation.title, exact: true })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.keyDown(list.getByRole('button', { name: `对话操作:${archivedConversation.title}` }), { key: 'Enter' });
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: '恢复到最近对话' }));
|
||||
|
||||
await waitFor(() => expect(list.queryByText('已归档对话')).not.toBeInTheDocument());
|
||||
expect(projectApi.patch).toHaveBeenCalledWith(archivedConversation.id, { archived: false });
|
||||
expect(list.getByRole('button', { name: archivedConversation.title, exact: true })).toBeVisible();
|
||||
expect(list.getByRole('button', { name: conversation.title, exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id);
|
||||
expect(conversationApi.snapshot).not.toHaveBeenCalledWith(archivedConversation.id);
|
||||
expect(projectApi.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps the new-conversation label visible in compact archive view and returns to recent conversations after creation', async () => {
|
||||
const archivedConversation = { ...conversation, archivedAt: '2026-09-20T00:00:00Z' };
|
||||
const createdConversation = { ...conversation, id: 'created-conversation', title: '新建的对话' };
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config });
|
||||
projectApi.conversations.mockResolvedValue([archivedConversation]);
|
||||
projectApi.create.mockResolvedValue(createdConversation);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
conversationApi.snapshot.mockResolvedValue(createLocalConversationSnapshot(project.id, createdConversation));
|
||||
const { CodingChatPanel: CoreChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { CodingProjectConversations } = await import('@/pages/Chat/CodingProjectConversations');
|
||||
const { codingConversationStore } = await import('@/stores/coding-conversations');
|
||||
const { useState } = await import('react');
|
||||
const CodingChatPanel = () => {
|
||||
const [archived, setArchived] = useState(true);
|
||||
return <>
|
||||
<CodingProjectConversations projectId={project.id} compact archived={archived} onArchivedChange={setArchived} />
|
||||
<CoreChatPanel />
|
||||
</>;
|
||||
};
|
||||
render(<CodingChatPanel />);
|
||||
const list = within(await screen.findByTestId('project-conversations'));
|
||||
const create = list.getByRole('button', { name: '新建对话', exact: true });
|
||||
expect(create).toHaveTextContent('新对话');
|
||||
expect(list.getByText('已归档对话')).toBeVisible();
|
||||
expect(projectApi.create).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(create);
|
||||
|
||||
await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(createdConversation.id));
|
||||
expect(projectApi.create).toHaveBeenCalledOnce();
|
||||
expect(list.queryByText('已归档对话')).not.toBeInTheDocument();
|
||||
expect(list.queryByRole('button', { name: '返回最近对话' })).not.toBeInTheDocument();
|
||||
expect(list.queryByRole('button', { name: archivedConversation.title, exact: true })).not.toBeInTheDocument();
|
||||
expect(list.getByRole('button', { name: createdConversation.title, exact: true })).toHaveAttribute('aria-current', 'page');
|
||||
expect(conversationApi.submit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renames an unselected conversation from its menu without preparing that conversation', async () => {
|
||||
@@ -959,7 +1054,7 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(screen.queryByText('metadata rejected')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps project settings only in the Conversation list and removes legacy Header actions', async () => {
|
||||
it('removes project toolbar actions and legacy Header actions while keeping conversation creation', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
|
||||
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
|
||||
@@ -986,10 +1081,12 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(within(header).queryByRole('button', { name: /标为(已读|未读)/ })).not.toBeInTheDocument();
|
||||
expect(within(header).queryByRole('button', { name: '归档' })).not.toBeInTheDocument();
|
||||
expect(within(header).queryByRole('button', { name: '伙伴设置' })).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('button', { name: '项目设置' })).toHaveLength(1);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '项目设置' }));
|
||||
expect(navigate).toHaveBeenCalledWith('/project-config');
|
||||
expect(screen.queryByRole('button', { name: '项目设置' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: '查看归档对话' })).not.toBeInTheDocument();
|
||||
const list = within(screen.getByTestId('project-conversations'));
|
||||
expect(list.getByRole('button', { name: '新建对话', exact: true })).toHaveTextContent('新对话');
|
||||
expect(list.getByRole('button', { name: `对话操作:${conversation.title}` })).toBeInTheDocument();
|
||||
expect(onOpenProjectSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('remounts pending interaction state when switching Conversations', async () => {
|
||||
|
||||
@@ -7,7 +7,7 @@ import { useSettingsStore } from '@/stores/settings';
|
||||
|
||||
type PeekProps = {
|
||||
sidebarPeekOpen?: boolean;
|
||||
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar') => void;
|
||||
onSidebarPeekChange?: (open: boolean, source: 'toggle' | 'focus' | 'sidebar' | 'titlebar' | 'menu') => void;
|
||||
forceExpanded?: boolean;
|
||||
};
|
||||
|
||||
@@ -42,7 +42,10 @@ vi.mock('@/components/layout/Sidebar', () => ({
|
||||
data-force-expanded={forceExpanded ? 'true' : 'false'}
|
||||
onPointerEnter={() => onSidebarPeekChange?.(true, 'sidebar')}
|
||||
onPointerLeave={() => onSidebarPeekChange?.(false, 'sidebar')}
|
||||
/>
|
||||
>
|
||||
<button onClick={() => onSidebarPeekChange?.(true, 'menu')}>打开项目菜单</button>
|
||||
<button onClick={() => onSidebarPeekChange?.(false, 'menu')}>关闭项目菜单</button>
|
||||
</aside>
|
||||
),
|
||||
}));
|
||||
|
||||
@@ -124,6 +127,20 @@ describe('MainLayout sidebar hover preview', () => {
|
||||
expect(screen.getByTestId('peek-trigger')).toHaveAttribute('data-peek-open', 'false');
|
||||
});
|
||||
|
||||
it('keeps the preview open for a project menu after the pointer leaves the sidebar', () => {
|
||||
renderLayout();
|
||||
const sidebar = screen.getByTestId('peek-sidebar');
|
||||
fireEvent.pointerEnter(sidebar);
|
||||
fireEvent.click(screen.getByRole('button', { name: '打开项目菜单' }));
|
||||
fireEvent.pointerLeave(sidebar);
|
||||
act(() => vi.advanceTimersByTime(300));
|
||||
expect(sidebar).toHaveAttribute('data-peek-open', 'true');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭项目菜单' }));
|
||||
act(() => vi.advanceTimersByTime(180));
|
||||
expect(sidebar).toHaveAttribute('data-peek-open', 'false');
|
||||
});
|
||||
|
||||
it('closes once after leaving the only hover zone', () => {
|
||||
renderLayout();
|
||||
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter, useLocation } from 'react-router-dom';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
|
||||
const projects = vi.hoisted(() => ['A', 'B'].map((name) => ({
|
||||
id: `project-${name.toLowerCase()}`,
|
||||
name,
|
||||
createdAt: '2026-10-13T00:00:00.000Z',
|
||||
updatedAt: '2026-10-13T00:00:00.000Z',
|
||||
lastOpenedAt: '2026-10-13T00:00:00.000Z',
|
||||
})));
|
||||
const codingState = vi.hoisted(() => ({
|
||||
projects,
|
||||
activeProjectId: projects[0].id,
|
||||
activeProject: projects[0],
|
||||
load: vi.fn().mockResolvedValue(undefined),
|
||||
setActiveProject: vi.fn(),
|
||||
removeProject: vi.fn(),
|
||||
createProject: vi.fn(),
|
||||
}));
|
||||
const projectConfigState = vi.hoisted(() => ({
|
||||
load: vi.fn<(projectId: string) => Promise<unknown>>(),
|
||||
remove: vi.fn(),
|
||||
}));
|
||||
const providerState = vi.hoisted(() => ({
|
||||
refreshProviderSnapshot: vi.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
vi.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({ t: (_key: string, fallback: string) => fallback }),
|
||||
}));
|
||||
vi.mock('@/lib/api-client', () => ({ invokeIpc: vi.fn() }));
|
||||
vi.mock('@/hooks/use-current-user-profile', () => ({
|
||||
useCurrentUserProfile: () => ({
|
||||
userProfile: { displayName: '测试用户', avatarUrl: null },
|
||||
profileRequired: false,
|
||||
profileSyncError: null,
|
||||
syncProfileNow: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
vi.mock('@/stores/auth', () => ({
|
||||
useAuthStore: (selector: (state: unknown) => unknown) => selector({ user: null, logout: vi.fn() }),
|
||||
}));
|
||||
vi.mock('@/stores/settings', () => ({
|
||||
useSettingsStore: (selector: (state: unknown) => unknown) => selector({ sidebarCollapsed: false }),
|
||||
}));
|
||||
vi.mock('@/stores/coding-workspace', () => ({
|
||||
codingWorkspaceStore: { getState: () => codingState },
|
||||
useCodingWorkspaceStore: (selector: (state: typeof codingState) => unknown) => selector(codingState),
|
||||
}));
|
||||
vi.mock('@/stores/project-config', () => ({
|
||||
useProjectConfigStore: (selector: (state: typeof projectConfigState) => unknown) => selector(projectConfigState),
|
||||
}));
|
||||
vi.mock('@/stores/providers', () => ({
|
||||
useProviderStore: (selector: (state: typeof providerState) => unknown) => selector(providerState),
|
||||
}));
|
||||
vi.mock('@/pages/Chat/CodingProjectConversations', () => ({
|
||||
CodingProjectConversations: ({ archived }: { archived: boolean }) => (
|
||||
<div data-testid="conversation-list-mode">{archived ? 'archived' : 'recent'}</div>
|
||||
),
|
||||
}));
|
||||
vi.mock('@/components/account/PointWallet', () => ({ PointWallet: () => null }));
|
||||
vi.mock('@/components/community/CommunityLink', () => ({ CommunityLink: () => null }));
|
||||
vi.mock('@/components/layout/ModuleSwitcher', () => ({ ModuleSwitcher: () => null }));
|
||||
vi.mock('@/components/layout/ImageWorkspaceSidebar', () => ({ ImageWorkspaceSidebar: () => null }));
|
||||
vi.mock('@/components/layout/SidebarUpdateButton', () => ({ SidebarUpdateButton: () => null }));
|
||||
vi.mock('@/components/profile/UserProfileDialog', () => ({ UserProfileDialog: () => null }));
|
||||
|
||||
function LocationProbe() {
|
||||
return <div data-testid="location">{useLocation().pathname}</div>;
|
||||
}
|
||||
|
||||
async function renderSidebar() {
|
||||
render(<MemoryRouter initialEntries={['/chat']}><Sidebar /><LocationProbe /></MemoryRouter>);
|
||||
await act(async () => undefined);
|
||||
projectConfigState.load.mockClear();
|
||||
}
|
||||
|
||||
async function selectProjectAction(projectName: string, action: string) {
|
||||
fireEvent.keyDown(screen.getByRole('button', { name: `项目操作:${projectName}` }), { key: 'Enter' });
|
||||
fireEvent.click(await screen.findByRole('menuitem', { name: action }));
|
||||
}
|
||||
|
||||
describe('Sidebar project actions', () => {
|
||||
beforeEach(() => {
|
||||
codingState.activeProjectId = projects[0].id;
|
||||
codingState.activeProject = projects[0];
|
||||
codingState.setActiveProject.mockReset();
|
||||
codingState.setActiveProject.mockImplementation(async (projectId: string) => {
|
||||
const project = projects.find((item) => item.id === projectId)!;
|
||||
codingState.activeProjectId = projectId;
|
||||
codingState.activeProject = project;
|
||||
return project;
|
||||
});
|
||||
projectConfigState.load.mockReset();
|
||||
projectConfigState.load.mockResolvedValue({ status: 'valid', config: {} });
|
||||
});
|
||||
|
||||
it.each(['项目设置', '已归档对话'])('%s for the current project supersedes an earlier pending project open', async (action) => {
|
||||
await renderSidebar();
|
||||
let resolveProjectB!: (value: unknown) => void;
|
||||
const pendingProjectB = new Promise((resolve) => { resolveProjectB = resolve; });
|
||||
projectConfigState.load.mockImplementation((projectId) => projectId === projects[1].id
|
||||
? pendingProjectB
|
||||
: Promise.resolve({ status: 'valid', config: {} }));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '进入项目 B' }));
|
||||
expect(projectConfigState.load).toHaveBeenCalledWith(projects[1].id);
|
||||
expect(codingState.setActiveProject).not.toHaveBeenCalled();
|
||||
await selectProjectAction('A', action);
|
||||
await waitFor(() => {
|
||||
if (action === '项目设置') expect(screen.getByTestId('location')).toHaveTextContent('/project-config');
|
||||
else expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('archived');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
resolveProjectB({ status: 'valid', config: {} });
|
||||
await pendingProjectB;
|
||||
});
|
||||
|
||||
expect(codingState.activeProjectId).toBe(projects[0].id);
|
||||
expect(codingState.setActiveProject).not.toHaveBeenCalledWith(projects[1].id);
|
||||
if (action === '项目设置') expect(screen.getByTestId('location')).toHaveTextContent('/project-config');
|
||||
else {
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/chat');
|
||||
expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('archived');
|
||||
}
|
||||
});
|
||||
|
||||
it.each(['项目设置', '已归档对话'])('rejects %s for an inactive project with invalid configuration', async (action) => {
|
||||
await renderSidebar();
|
||||
projectConfigState.load.mockResolvedValue({ status: 'invalid', config: null, error: '配置文件无效' });
|
||||
|
||||
await selectProjectAction('B', action);
|
||||
|
||||
expect(await screen.findByRole('alertdialog')).toHaveTextContent('配置文件无效');
|
||||
expect(projectConfigState.load).toHaveBeenCalledWith(projects[1].id);
|
||||
expect(codingState.setActiveProject).not.toHaveBeenCalled();
|
||||
expect(codingState.activeProjectId).toBe(projects[0].id);
|
||||
expect(screen.getByTestId('location')).toHaveTextContent('/chat');
|
||||
expect(screen.getByTestId('conversation-list-mode')).toHaveTextContent('recent');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user