feat(coding): nest Conversations under Agents
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
# Task: Nest Conversations under the selected Agent
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260825-agent-conversation-tree-4d8a2c71
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260825-agent-conversation-tree-4d8a2c71-agent-conversation-tree
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-agent-conversation-tree-4d8a2c71
|
||||
- Base commit: c4ca16fa198a1e5a90bd1b72bf0cee22f6ee1baa
|
||||
- Owner: codex-root
|
||||
- Status: Done
|
||||
|
||||
## Scope
|
||||
|
||||
- Change the Makelore Code sidebar so the selected Agent is the visible parent of its Conversations instead of rendering separate sibling `伙伴` and `对话` sections.
|
||||
- Preserve the existing `agentId` ownership/filtering, Conversation selection, lazy first-Conversation creation, unread/running indicators, and new-Conversation action.
|
||||
- Add focused Renderer coverage and an Electron E2E assertion for the hierarchy; do not change project metadata, Host APIs, Pi runtime, or user data.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- Render each Agent as a collapsible parent row. Only the selected Agent expands, and its Conversation list is placed immediately beneath it with a clear `${agent.name} 的对话` label.
|
||||
- Keep all existing interactive hit areas at least 40px and retain the product's single light visual system, exact-property press transitions, and current button semantics.
|
||||
- Do not add a compatibility layer, new state model, animation dependency, or Agent/Conversation migration. The persisted relationship is already correct.
|
||||
- The user requested direct implementation without subagents.
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
Task context:
|
||||
- Task ID: `20260825-agent-conversation-tree-4d8a2c71`
|
||||
- Mode: feature
|
||||
- Branch: `codex/20260825-agent-conversation-tree-4d8a2c71-agent-conversation-tree`
|
||||
- Worktree: `D:\Datas\OthersProjects\makelore-agent-conversation-tree-4d8a2c71`
|
||||
- Base commit: `c4ca16fa198a1e5a90bd1b72bf0cee22f6ee1baa`
|
||||
- Other active local tasks: eleven non-ready owners were inspected through their task records.
|
||||
- Overlap or semantic-conflict assessment: the only adjacent task is the older undefined `partner-create-model-parity` diagnosis. This task does not change partner/model semantics or its files; the old Main OpenCode integration and all Canvas/Robot/Learning tasks are unrelated. No semantic conflict blocks this UI-only change.
|
||||
|
||||
Read:
|
||||
- `.project-docs/05-agent-entry/memory-index.md`
|
||||
- `.project-docs/05-agent-entry/read-before-planning.md`
|
||||
- `.project-docs/05-agent-entry/planning-gate.md`
|
||||
- `.project-docs/00-brief/project-positioning.md`
|
||||
- `.project-docs/00-brief/success-criteria.md`
|
||||
- `.project-docs/30-worklog/current-state.md`
|
||||
- `.project-docs/10-decisions/decision-index.md`
|
||||
- `.project-docs/20-architecture/system-overview.md`
|
||||
- `.project-docs/20-architecture/module-map.md`
|
||||
- `.project-docs/20-architecture/data-flow.md`
|
||||
- `.project-docs/40-domain/business-rules.md`
|
||||
- `.project-docs/40-domain/glossary.md`
|
||||
- `.project-docs/50-evidence/evidence-index.md`
|
||||
- `.project-docs/60-reflection/reflection-index.md`
|
||||
- `.project-docs/80-commitments/commitments.md`
|
||||
- `.project-docs/90-maintenance/stale-items.md`
|
||||
- Diagnosis record `20260825-conversation-agent-binding-8a4c2e71` and PI-120/PI-130 implementation records.
|
||||
|
||||
Relevant understanding:
|
||||
- Project goal: keep Makelore Code local and project-scoped, with Renderer access through typed facades and Main ownership of runtime and secrets.
|
||||
- Current integrated focus: the live cumulative source is the Pi hard-cut product; shared canonical memory still contains stale OpenCode-era module descriptions.
|
||||
- Active task scope: improve only the Agent → Conversation visual hierarchy in `CodingChatPanel`.
|
||||
- Active constraints: no runtime/API/storage changes; preserve all target isolation and selection behavior.
|
||||
- Decisions affecting this task: Conversation metadata is Agent-owned through mandatory `agentId`; only the selected Agent's unarchived Conversations are rendered.
|
||||
- Evidence, reflections, or commitments affecting this task: installed `testgame` has two Conversations both uniquely owned by `ccc`; no orphaned metadata exists. No shared commitment changes this UI work.
|
||||
- Files or modules likely involved: `src/pages/Chat/CodingChatPanel.tsx`, `tests/unit/coding-chat-panel.test.tsx`, and `tests/e2e/pi-coding-first-chat.spec.ts`.
|
||||
- Unknowns, stale docs, or conflicts: canonical project positioning is a placeholder and module map is OpenCode-stale; current source plus Pi task records are authoritative. No unresolved behavior conflict remains.
|
||||
|
||||
Gate result:
|
||||
- Passed.
|
||||
|
||||
## Plan
|
||||
|
||||
1. Add a focused failing Renderer test that requires the selected Agent to expose an expanded child Conversation group and hides another Agent's Conversations.
|
||||
2. Move the existing Conversation header/list/create/loading block inside the selected Agent's rendered group, using a parent surface plus an indented child rail without changing selection or creation logic.
|
||||
3. Add a small production-seam E2E assertion for the visible Agent-owned group, then run focused tests, typecheck, lint, `build:vite`, and the applicable Electron E2E.
|
||||
4. Update this task record with actual outcome and verification, run the documentation gate, and commit only task-owned changes.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Replaced the sibling `伙伴` / `对话` sidebar sections with one `伙伴与对话` hierarchy.
|
||||
- Each Agent is now an accessible expandable parent row. The selected Agent uses a concentric selected surface, expanded chevron, `aria-expanded`, and an immediately nested child group labelled `${agent.name} 的对话`.
|
||||
- Moved the existing Conversation rows, create action, first-Conversation loading indicator, unread marker, and run spinner into the selected Agent's child group without changing any state, API, storage, or runtime behavior.
|
||||
- Kept unselected Agents collapsed, so a Conversation cannot visually appear at project level or under another Agent.
|
||||
- Updated `README.md` to describe the nested Agent-owned Conversation list.
|
||||
|
||||
## Verification
|
||||
|
||||
- Red phase: the new hierarchy regression failed against the prior sibling layout while the other 15 `coding-chat-panel` tests passed.
|
||||
- `corepack pnpm exec vitest run tests/unit/coding-chat-panel.test.tsx --maxWorkers=1`: passed, 1 file / 16 tests.
|
||||
- `corepack pnpm run typecheck`: passed.
|
||||
- `corepack pnpm run lint:check`: passed with 0 errors and 5 pre-existing warnings outside the changed files.
|
||||
- `corepack pnpm run build:vite`: passed; existing dynamic-import and chunk-size warnings remain.
|
||||
- `corepack pnpm test`: passed; functional stage 179 files / 1523 passed / 2 skipped, isolated pressure stage 1 file / 1 passed.
|
||||
- `corepack pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts`: passed, 2/2. The production Renderer seam asserts the expanded `Builder` parent, nested `Builder 的对话` group, first Conversation, and second-Conversation selection inside that group.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- A rebuilt installer is not part of this UI source task; package from this commit only when the user requests a new installer.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- None recorded.
|
||||
@@ -126,7 +126,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p
|
||||
- 读取旧项目时会把仍缺少模型的伙伴从兼容保留的 `defaultModel` 自动迁移到伙伴自身配置,之后运行时只认伙伴模型。
|
||||
- 技能资源入口使用扳手图标;点击已安装 Skill 后先展示其目录结构,再展示主文件 `SKILL.md` 原文,并支持返回技能列表。
|
||||
- 一个伙伴可以拥有多条互相独立的 Pi Session;伙伴和 Conversation 元数据分别由项目配置与 `.niancode/conversations.json` 保存,稳定 Agent/Conversation id 保持本地历史连续。
|
||||
- 核心聊天页左侧按伙伴展示本地 Conversation,并提供新建入口;首次选择没有 Conversation 的伙伴时立即创建本地元数据,同时异步准备对应运行时。即使准备被阻塞或超时,输入框仍可编辑,草稿也不会丢失。
|
||||
- 核心聊天页左侧把本地 Conversation 嵌套在展开的所属伙伴下,并在该伙伴子组中提供新建入口;首次选择没有 Conversation 的伙伴时立即创建本地元数据,同时异步准备对应运行时。即使准备被阻塞或超时,输入框仍可编辑,草稿也不会丢失。
|
||||
- Conversation 历史按需从 Main-owned Snapshot 读取。公开 SSE 只交付 Snapshot 与按 Conversation、worker generation 分组的 `patch-batch`;Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation,隐藏 Conversation 的流式更新不会提交选中时间线。
|
||||
- 核心时间线渲染消息、Markdown、thinking、工具、压缩、轮次边界、通知和 `subagent.v1` 单个/并行/串行子任务;默认保留最近 120 个节点的渲染窗口,可按 100 个节点加载更早内容。工具结果和浏览器附件保留在对应工具卡片内,累计输出覆盖同一块而不形成独立气泡;压缩只展示产品摘要、重试和结算状态。
|
||||
- Composer 支持文字、粘贴或选择 PNG/JPEG/WebP/GIF 图片,每条消息最多 16 张、最多并行上传 4 张。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;Main 在落盘前核对 MIME 与最小图片签名,状态与事件只保存 attachment id,时间线按需读取二进制并创建临时 object URL,不保存重复 base64。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Bot,
|
||||
ChevronRight,
|
||||
CircleAlert,
|
||||
LoaderCircle,
|
||||
MessageSquarePlus,
|
||||
@@ -558,74 +559,106 @@ export function CodingChatPanel({
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">本地 Conversation</p>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">伙伴</p>
|
||||
<div className="space-y-1">
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
key={agent.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-h-10 w-full items-center gap-2 rounded-xl px-2.5 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
|
||||
selectedAgent?.id === agent.id ? 'bg-background font-medium shadow-soft' : 'hover:bg-background/70',
|
||||
)}
|
||||
onClick={() => {
|
||||
clearConversationSelection();
|
||||
selectAgent(agent.id);
|
||||
}}
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-foreground text-xs font-semibold text-background">
|
||||
{agent.name.trim().slice(0, 1) || 'A'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex min-h-10 items-center justify-between px-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">对话</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-xl transition-transform duration-150 ease-out active:scale-[0.96]"
|
||||
aria-label="新建对话"
|
||||
disabled={!selectedAgent || Boolean(selectedAgent && creatingAgentIds[selectedAgent.id])}
|
||||
onClick={() => void handleCreateConversation()}
|
||||
>
|
||||
{selectedAgent && creatingAgentIds[selectedAgent.id]
|
||||
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
: <MessageSquarePlus className="h-4 w-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{agentConversations.map((conversation) => {
|
||||
const unread = conversationSummaries[conversation.id]?.unread ?? conversation.unread;
|
||||
const summary = conversationSummaries[conversation.id];
|
||||
<p className="px-2 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">伙伴与对话</p>
|
||||
<div className="space-y-1.5">
|
||||
{agents.map((agent) => {
|
||||
const expanded = selectedAgent?.id === agent.id;
|
||||
const conversationGroupId = `agent-conversations-${agent.id}`;
|
||||
return (
|
||||
<button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
<div
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
'flex min-h-10 w-full items-center gap-2 rounded-xl px-3 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
|
||||
targetConversationId === conversation.id
|
||||
? 'bg-background font-medium shadow-soft'
|
||||
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground',
|
||||
'rounded-2xl',
|
||||
expanded && 'bg-background p-1 shadow-soft',
|
||||
)}
|
||||
onClick={() => handleSelectConversation(conversation)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{conversation.title}</span>
|
||||
{summary && ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(summary.runStatus) && (
|
||||
<LoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin text-brand" aria-label="对话正在运行" />
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-h-10 w-full items-center gap-2 rounded-xl px-2.5 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
|
||||
expanded ? 'font-semibold' : 'hover:bg-background/70',
|
||||
)}
|
||||
aria-label={agent.name}
|
||||
aria-expanded={expanded}
|
||||
aria-controls={conversationGroupId}
|
||||
onClick={() => {
|
||||
clearConversationSelection();
|
||||
selectAgent(agent.id);
|
||||
}}
|
||||
>
|
||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-foreground text-xs font-semibold text-background" aria-hidden="true">
|
||||
{agent.name.trim().slice(0, 1) || 'A'}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{agent.name}</span>
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
'h-4 w-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out',
|
||||
expanded && 'rotate-90',
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div
|
||||
id={conversationGroupId}
|
||||
role="group"
|
||||
aria-label={`${agent.name} 的对话`}
|
||||
className="mb-1 ml-3 mt-1 border-l border-foreground/10 pl-3 pr-1"
|
||||
>
|
||||
<div className="flex min-h-10 items-center justify-between pl-2">
|
||||
<p className="min-w-0 truncate text-[11px] font-semibold text-muted-foreground">
|
||||
{agent.name} 的对话
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 rounded-xl transition-transform duration-150 ease-out active:scale-[0.96]"
|
||||
aria-label="新建对话"
|
||||
disabled={Boolean(creatingAgentIds[agent.id])}
|
||||
onClick={() => void handleCreateConversation()}
|
||||
>
|
||||
{creatingAgentIds[agent.id]
|
||||
? <LoaderCircle className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
: <MessageSquarePlus className="h-4 w-4" aria-hidden="true" />}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-1 pb-1">
|
||||
{agentConversations.map((conversation) => {
|
||||
const unread = conversationSummaries[conversation.id]?.unread ?? conversation.unread;
|
||||
const summary = conversationSummaries[conversation.id];
|
||||
return (
|
||||
<button
|
||||
key={conversation.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-h-10 w-full items-center gap-2 rounded-xl px-3 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]',
|
||||
targetConversationId === conversation.id
|
||||
? 'bg-surface-subtle font-medium'
|
||||
: 'text-muted-foreground hover:bg-surface-subtle/70 hover:text-foreground',
|
||||
)}
|
||||
onClick={() => handleSelectConversation(conversation)}
|
||||
>
|
||||
<span className="min-w-0 flex-1 truncate">{conversation.title}</span>
|
||||
{summary && ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(summary.runStatus) && (
|
||||
<LoaderCircle className="h-3.5 w-3.5 shrink-0 animate-spin text-brand" aria-label="对话正在运行" />
|
||||
)}
|
||||
{unread && <span className="h-2 w-2 shrink-0 rounded-full bg-brand" aria-label="未读" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{autoCreating && (
|
||||
<div className="flex min-h-10 items-center gap-2 px-3 text-xs text-muted-foreground">
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
正在创建首个对话…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{unread && <span className="h-2 w-2 shrink-0 rounded-full bg-brand" aria-label="未读" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{autoCreating && (
|
||||
<div className="flex min-h-10 items-center gap-2 px-3 text-xs text-muted-foreground">
|
||||
<LoaderCircle className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
正在创建首个对话…
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
@@ -448,6 +448,11 @@ test('first PI Conversation is editable under 500 ms and submits before runtime
|
||||
performance.mark('pi-first-chat-start');
|
||||
window.location.hash = '/chat';
|
||||
});
|
||||
const builderButton = page.getByRole('button', { name: 'Builder', exact: true });
|
||||
await expect(builderButton).toHaveAttribute('aria-expanded', 'true');
|
||||
const builderConversations = page.getByRole('group', { name: 'Builder 的对话' });
|
||||
await expect(builderConversations).toBeVisible();
|
||||
await expect(builderConversations.getByRole('button', { name: '新对话', exact: true })).toBeVisible();
|
||||
const composer = page.getByRole('textbox');
|
||||
await expect(composer).toBeEnabled();
|
||||
const editableMs = await page.evaluate(() => (
|
||||
@@ -560,7 +565,9 @@ test('PI feature UI isolates Conversations and exposes queue, interaction, model
|
||||
await page.getByRole('button', { name: /\/review/ }).click();
|
||||
await expect(page.getByRole('textbox')).toHaveValue('/review ');
|
||||
|
||||
await page.getByRole('button', { name: 'Second Conversation' }).first().click();
|
||||
const builderConversations = page.getByRole('group', { name: 'Builder 的对话' });
|
||||
await expect(builderConversations).toBeVisible();
|
||||
await builderConversations.getByRole('button', { name: 'Second Conversation' }).click();
|
||||
await expect(page.getByTestId('coding-conversation-header')).toContainText('Second Conversation');
|
||||
await expect(page.getByRole('combobox', { name: '当前对话模型' })).toHaveValue(
|
||||
JSON.stringify(['account-e2e', 'model-b']),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
@@ -267,6 +267,40 @@ describe('CodingChatPanel first Conversation', () => {
|
||||
expect(codingConversationStore.getState().selectedConversationId).toBe(reviewerConversation.id);
|
||||
});
|
||||
|
||||
it('renders Conversations as children of only the selected Agent', async () => {
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
projectApi.config.mockResolvedValue({ project, config: configForAgents([agent, reviewer]) });
|
||||
projectApi.conversations.mockResolvedValue([conversation, reviewerConversation]);
|
||||
conversationApi.events.mockResolvedValue(new FakeEventSource() as unknown as EventSource);
|
||||
conversationApi.recover.mockResolvedValue(undefined);
|
||||
const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel');
|
||||
const { createLocalConversationSnapshot } = await import('@/pages/Chat/coding-chat-snapshot');
|
||||
conversationApi.snapshot.mockImplementation(async (conversationId: string) => (
|
||||
createLocalConversationSnapshot(
|
||||
project.id,
|
||||
conversationId === reviewerConversation.id ? reviewerConversation : conversation,
|
||||
)
|
||||
));
|
||||
render(<CodingChatPanel />);
|
||||
|
||||
const builderButton = await screen.findByRole('button', { name: agent.name });
|
||||
const reviewerButton = screen.getByRole('button', { name: reviewer.name });
|
||||
expect(builderButton).toHaveAttribute('aria-expanded', 'true');
|
||||
expect(reviewerButton).toHaveAttribute('aria-expanded', 'false');
|
||||
const builderConversations = screen.getByRole('group', { name: `${agent.name} 的对话` });
|
||||
expect(within(builderConversations).getByRole('button', { name: conversation.title })).toBeVisible();
|
||||
expect(screen.queryByText(reviewerConversation.title)).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(reviewerButton);
|
||||
|
||||
await waitFor(() => expect(reviewerButton).toHaveAttribute('aria-expanded', 'true'));
|
||||
expect(builderButton).toHaveAttribute('aria-expanded', 'false');
|
||||
const reviewerConversations = screen.getByRole('group', { name: `${reviewer.name} 的对话` });
|
||||
expect(within(reviewerConversations)
|
||||
.getByRole('button', { name: reviewerConversation.title })).toBeVisible();
|
||||
expect(screen.queryByText(conversation.title)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not let a pending fork steal selection or Header busy state after a Conversation switch', async () => {
|
||||
const forkFlight = deferred<CodingConversationMetadata>();
|
||||
projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });
|
||||
|
||||
Reference in New Issue
Block a user