diff --git a/.project-docs/30-worklog/tasks/20260825-agent-conversation-tree-4d8a2c71.md b/.project-docs/30-worklog/tasks/20260825-agent-conversation-tree-4d8a2c71.md new file mode 100644 index 0000000..2e56e27 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260825-agent-conversation-tree-4d8a2c71.md @@ -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. diff --git a/README.md b/README.md index bf500e3..541d3f4 100644 --- a/README.md +++ b/README.md @@ -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。 diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index dfe8c01..f571f2e 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -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({

本地 Conversation

-

伙伴

-
- {agents.map((agent) => ( - - ))} -
-
-

对话

- -
-
- {agentConversations.map((conversation) => { - const unread = conversationSummaries[conversation.id]?.unread ?? conversation.unread; - const summary = conversationSummaries[conversation.id]; +

伙伴与对话

+
+ {agents.map((agent) => { + const expanded = selectedAgent?.id === agent.id; + const conversationGroupId = `agent-conversations-${agent.id}`; return ( - + {expanded && ( +
+
+

+ {agent.name} 的对话 +

+ +
+
+ {agentConversations.map((conversation) => { + const unread = conversationSummaries[conversation.id]?.unread ?? conversation.unread; + const summary = conversationSummaries[conversation.id]; + return ( + + ); + })} + {autoCreating && ( +
+
+ )} +
+
)} - {unread && } - +
); })} - {autoCreating && ( -
-
- )}
diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index f292050..a36425a 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -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']), diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index a2754fb..2e2af1e 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -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(); + + 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(); projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id });