diff --git a/.project-docs/30-worklog/tasks/20260920-coding-session-controls-10fee632.md b/.project-docs/30-worklog/tasks/20260920-coding-session-controls-10fee632.md new file mode 100644 index 0000000..25aa0af --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260920-coding-session-controls-10fee632.md @@ -0,0 +1,48 @@ +# Task: Implement coding conversation archive and naming + +## Identity + +- Task ID: 20260920-coding-session-controls-10fee632 +- Mode: Feature +- Branch: codex/20260920-coding-session-controls-10fee632-coding-session-controls +- Worktree: D:\Datas\OthersProjects\.codex-worktrees\makelore\20260920-coding-session-controls-10fee632 +- Base commit: d61226532a5946af4a0b12b4eb02ce9059d20ddb +- Owner: codex +- Status: Ready for Integration + +## Scope + +- Implement the approved coding conversation archive/restore, shared rename UI, and first-user-message titles in MakeLore. Own conversation metadata/service/event transport, related Renderer state/UI, focused tests, and README guidance. + +## Intent And Constraints + +- Concurrent Task Gate and Planning Gate passed. Official start/status matched this task, feature branch, isolated worktree and base. Read entry, active task, memory index, positioning/current-state, decisions (Pi ADR-006 and official model capabilities), architecture/domain/success criteria, evidence/commitment context and all peer task scope/intent/promotion sections. Template-only peer records remain unknown; no concrete conflicting dependency affects this isolated change. Positioning is still a template; executable code, README and accepted ADRs establish the actual boundaries. +- Follow the user-approved design in server task 20260920-ai-session-management-23c282e7 (commit 81fc375f). Main owns .makelore/conversations.json; no Pi title double-write, no automatic historical renaming, no interrupted jobs on archive, no provider secret exposure, and no changes to canonical project memory. No subagents. +- User amended automatic naming during implementation: use the first user message directly, consistent with their requested Codex behavior. This supersedes the earlier model-generated-title proposal. The final implementation uses the first nonempty user message's first line, normalized whitespace and at most 32 characters; image-only uses 附件对话. No model request or changes to Pi execution code. +- Plan: extend serialized metadata operations and one-shot Main naming lifecycle; wire metadata invalidation and archive guards; implement list menus/archive view/shared rename dialog; verify focused persistence/service/UI regressions, type checking, lint/build and supported Electron fixtures; record evidence and durable handoff. + +## Outcome + +- Implemented row menus and a per-Agent archived view with archive/restore, preserved running/unread indicators, and a shared 200-character rename dialog (keyboard submit/cancel, selected input, pending/error state). Menu actions do not select or prepare their target. Archiving the last recent conversation leaves an empty state instead of creating a replacement. +- Archive only changes metadata. Existing work/approvals/abort remain available; archived conversations show a restore action in place of the composer. Main rejects new prompt/steer/follow-up and fork requests while allowing retrieval of an already accepted request. +- Automatic titles use the first actual complete user message, first line, whitespace normalization and 32-character bound; image-only messages use 附件对话. A Main lifetime subscription works without a Renderer stream; hydration can recover an unassigned first title. Serialized storage applies at most once and rechecks ownership, so manual titles and deleted conversations cannot be overwritten or resurrected. Historical records default to manual; fork titles are manual. Title-only changes do not move the conversation. +- Added internal titleMode/autoTitleSet fields in schema-v2 metadata without a migration or Pi title double-write. Public responses omit these internal fields. Metadata invalidation uses the existing SSE connection without fake runtime cursors. List-only refresh coalesces events, follows changes arriving during reads, refreshes on reconnect, and discards stale results across metadata mutations/project loads. +- README describes the final behavior. No runtime provider, model-call, dependency, server, or canonical project-memory changes. Independent worktrees and unrelated original-worktree changes remain untouched. + +## Verification + +- pnpm install --frozen-lockfile --offline: passed using pnpm 10.33.4; lockfile unchanged. +- Focused Vitest: 114/114 passed across coding-projects-schema-v2, coding-conversation-title, coding-core-routes, coding-workspace-store, coding-chat-panel, coding-conversations-store, and pi-managed-user-entry-fork-real. The last test launches the real pinned Pi 0.84.2 runtime against local persisted history, verifies first-message naming and independent manual fork metadata; no real Provider account was used. +- coding-chat-pressure.test.tsx: 1/1 passed separately with one worker. +- Electron Playwright: conversation menus rename/archive/restore test passed (1/1), including non-selected rename, archived composer replacement, restore selection and no abort request. Test screenshot inspected at test-results/coding-session-controls.png (ignored generated output). This is a Host-fixture UI test, not a live Provider turn. +- pnpm run typecheck: passed. pnpm run lint:check: passed with 0 errors and 8 existing warnings. pnpm run build:vite: passed for Renderer/Main/preload/utility targets. git diff --check: passed. +- Explicit Main tsc remains non-passing due repository baseline diagnostics. Compared current and recorded-base sources using the TypeScript compiler API and an in-memory Git-blob overlay (same dependencies/options): 89 baseline diagnostics, 88 current, no added diagnostics. The removed diagnostic is the SSE union cursor access corrected while adding metadata events. No unrelated type repair was attempted. +- Initial checks caught two refresh tests blocked by waiting on an old project's metadata write and an ambiguous test button query; fixed the project-switch wait and scoped the UI assertion. The recorded focused suite subsequently passed. + +## Follow-ups + +- Ready for review/integration from this isolated feature branch; no merge, push, package or deployment performed. Existing Main type diagnostics remain repository follow-up work outside this feature. + +## Promotion Candidates + +- Targets: architecture data-flow/module-map and domain business-rules. Proposal: local conversation metadata owns archive and title state, Main names only new automatic conversations from the first user message, manual and historical titles are stable, and metadata invalidation is separate from runtime sequenced patches. Evidence: focused persistence/service/Renderer tests, real Pi history hydration and Electron interaction fixture above. Future impact: prevents reintroducing model-based title generation or treating archive as runtime disposal. No conflict with accepted Pi ownership; the user's explicit first-message amendment supersedes the earlier design proposal's model-generated title. No additional human product decision required; canonical promotion still requires the Integration Gate. diff --git a/README.md b/README.md index c6aaad0..98a9cc1 100644 --- a/README.md +++ b/README.md @@ -131,6 +131,9 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p ### 项目智能体与对话 +- 对话行的“…”菜单支持重命名和归档,智能体下的归档入口可查看并恢复原对话。归档保留历史、附件和 Pi Session,不中止正在执行的任务;已归档对话仍可查看、处理审批或中止,恢复后才能继续发送或创建分支。全部归档后显示空列表,不自动创建空对话。 +- 新对话使用第一条实际用户消息的首行作为标题,合并空白并截取最多 32 个字符;纯图片消息使用“附件对话”,命令和空消息不参与命名。不额外调用模型,不随后续消息改名。历史标题保持原样,手动重命名优先,标题最多 200 个字符;仅改标题不改变最近对话排序。Main 在本地元数据落盘后通过独立的 metadata-changed 事件通知列表,事件重连也会刷新列表。 + - 新项目默认没有智能体;用户在项目内创建智能体时必须填写名称、预设头像或本地头像、职责说明和精确的 Provider 账号与默认模型。该选择以 `{ accountId, modelId, thinkingLevel }` 保存,不依赖运行时私有 Provider id。本地头像会自动裁剪为 256×256,并优先压缩为 WebP 后随项目配置保存;系统指令与需要按智能体分配的插件能力属于高级设置。 - Makelore 为每个项目智能体附加平台管理的中文语言规则:自然语言默认使用简体中文,包括可见的思考过程、进度说明、工具调用说明和最终回复;代码、命令、路径、日志、错误原文与必要引用保留原始语言。用户或项目智能体系统指令明确要求其他语言时按该要求执行;模型仍不遵循时视为 Provider 模型行为。 - 有效项目可以直接进入对话工作区;`initialized` 只作旧 schema 兼容,不参与导航。尚无可用智能体时,聊天页保留品牌引导并显示非阻塞的设置入口,不伪造智能体或 Conversation;用户可以稍后进入项目配置完成设置。 @@ -138,7 +141,7 @@ Pi 正式包必须继续运行 `pnpm run verify:artifact:pi`、`pnpm run smoke:p - 项目智能体只保存自身的精确默认模型;未完成模型选择的智能体保持待配置状态,不从项目级 `defaultModel` 或运行时私有配置回退。 - 一个智能体可以拥有多条互相独立的 Pi Session;智能体和对话元数据分别由 `.makelore/project.json` 与 `.makelore/conversations.json` 保存,稳定的智能体与对话 id 保持本地历史连续。 - 核心聊天页左侧把本地对话嵌套在展开的所属智能体下,并在该智能体子组中提供新建入口;首次选择没有对话的智能体时立即创建本地元数据,同时异步准备对应运行时。即使准备被阻塞或超时,输入框仍可编辑,草稿也不会丢失。 -- Conversation 历史按需从 Main-owned Snapshot 读取。Session hydration 沿完整 active branch 投影可见历史;公开 SSE 只交付 Snapshot 与按 Conversation、worker generation 分组的 `patch-batch`。Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation,隐藏 Conversation 的流式更新不会提交选中时间线。Main 发出 `lifecycle:sleep` 时页面关闭旧事件流;视图挂载、项目上下文变化、窗口重新可见或获得焦点时,已选 Conversation 会静默重读权威 Snapshot,从而收敛后台已结束的任务,但绝不重放已接受或状态不确定的 mutation。时间线默认挂载最近 120 个节点,接近顶部时按 100 个节点自动追加更早内容并补偿新增高度,保持当前阅读锚点。 +- Conversation 历史按需从 Main-owned Snapshot 读取。Session hydration 沿完整 active branch 投影可见历史;公开 SSE 交付 Snapshot、会话元数据变更通知,以及按 Conversation、worker generation 分组的 `patch-batch`。Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation,隐藏 Conversation 的流式更新不会提交选中时间线。Main 发出 `lifecycle:sleep` 时页面关闭旧事件流;视图挂载、项目上下文变化、窗口重新可见或获得焦点时,已选 Conversation 会静默重读权威 Snapshot,从而收敛后台已结束的任务,但绝不重放已接受或状态不确定的 mutation。时间线默认挂载最近 120 个节点,接近顶部时按 100 个节点自动追加更早内容并补偿新增高度,保持当前阅读锚点。 - 核心时间线按每条用户消息组织为“用户消息、连续处理过程、结论回复”。Pi 的流式 thinking、工具、压缩、重试、通知和 `subagent.v1` 单个/并行/串行子任务共享一条轻量过程轨道;运行时显示“处理中”和活动指示,完成后按耗时(历史缺少计时时按项目数)收纳为可展开的“已处理”摘要。文字层级按结构化事件类型划分,不按文案关键词猜测:助手在工具调用前后直接对用户说的过程正文与最终结论都使用黑色;thinking、工具输入输出、压缩、子任务和运行状态使用浅灰辅助文字,仍在运行的灰色缩略文字带有低对比度 Shimmer 擦拭高光,结束后停止动画;错误状态保持红色。折叠的 thinking、助手过程正文和工具输出固定展示第一个可见块的首个非空行,横向偏移始终为零;尾部流式更新不会替换未变化的首行预览,展开后仍可查看完整内容。两类流式文字均在原位更新且不做位移动画,助手过程正文也不使用 Shimmer。工具调用用一行摘要持续展示工具名称、最新输入或输出和运行状态,并可按流式批次轻量整行轮动。思考、助手过程正文和工具节点均可展开查看完整详情,工具的累计输出和浏览器附件留在对应的二级展开区而不形成独立气泡。结论回复按安全 Markdown 排版标题、列表、引用、代码和表格;可点击的 HTTP/HTTPS 与本地 HTML 链接统一使用无底色的橙色文字。左键通过 Main-owned 系统入口交给默认浏览器打开;右键可打开系统菜单,网页链接可复制地址,本地链接还可在访达或系统文件夹中显示。同一回复已经给出完整 HTML 路径时,对应的相对查看路径也会解析到该文件。内部 `turn-start`/`turn-end` 只维持事件顺序、不呈现在界面。 - Composer 使用贴底的复合输入面板,支持文字、登录后的语音转写,以及粘贴或选择 PNG/JPEG/WebP/GIF 图片;每条消息最多 16 张图片、最多并行上传 4 张。语音录制通过现有 Main-owned Works Speech Host API 转成文字,并在项目或账号切换时立即停止;当前对话的模型与思考强度组合入口、语音及发送/中止入口保留在底部控制行,模型入口默认使用透明底色。模型与思考强度通过向上展开的分级菜单切换,只列出当前 Provider 或 Makelore 已验证模型档案实际支持的选项;Qwen3.6 Plus 使用混合思考开关,Qwen3.8 Max 暴露低/中/高三档并把产品“高”映射到服务端 `xhigh`。模型仅报告 `off` 时明确显示“不可调”,不伪造未知模型的推理档位。切换完成后静默刷新当前 Snapshot,只有真正恢复 worker 时才显示本地 Agent 重连提示。重复读取 Snapshot 不发布无变化的 worker 状态;Snapshot 与 SSE 批次竞态时,Renderer 会丢弃已覆盖事件并只应用连续的新尾部,避免形成伪重连循环。上下文接近模型限制或发生溢出时由 Pi 自动整理,Composer 不提供手动整理入口;自动整理的进度和结果仍显示在对话过程轨道中。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;Main 在落盘前核对 MIME 与最小图片签名,状态与事件只保存 attachment id,时间线按需读取二进制并创建临时 object URL,不保存重复 base64。发送后立即展示图片附件;Pi 实时消息与历史会话恢复都将图片映射到同一附件存储,重复加载复用已有文件。智能体报告 `changed-file.v1` 后,输入框上方会展示当前轮次的文件数与增删行统计,并可展开查看 Main-owned 变更快照中的逐文件差异。 - Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。最终产品中的 Pi 运行时会通过受控 Provider-shaped 回环 smoke 验证同一 Agent Server 内多个 Conversation 逻辑线程的重叠执行、状态隔离、凭证引用隔离、单线程关闭和 Server 崩溃后重启,以及独立子 Agent 进程的隔离与回收;真实外部 Provider 的并发、限流、协议兼容和凭证隔离仍是独立风险,未执行真实 Provider 验证时不得标记为 Pass。 diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index 2fcd489..b66e32b 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -555,6 +555,7 @@ export function createCodingComposition( await agentServer.stop(); }, async shutdown() { + conversations.dispose(); gameResourceDelivery.dispose(); previewDataSession?.dispose(); if (typeof options.browser.configurePreviewDataSession === 'function') { diff --git a/electron/api/routes/coding-conversations.ts b/electron/api/routes/coding-conversations.ts index edf9dbd..e714b87 100644 --- a/electron/api/routes/coding-conversations.ts +++ b/electron/api/routes/coding-conversations.ts @@ -89,7 +89,8 @@ export async function handleCodingConversationRoutes( res, event.type, event, - `${event.conversationId}:${event.workerGeneration}:${event.toSeq}`, + event.type === 'conversation.metadata-changed' ? undefined + : `${event.conversationId}:${event.workerGeneration}:${event.type === 'snapshot' ? event.seq : event.toSeq}`, )) break; } } finally { diff --git a/electron/api/routes/coding-route-errors.ts b/electron/api/routes/coding-route-errors.ts index 5fe5618..cf05378 100644 --- a/electron/api/routes/coding-route-errors.ts +++ b/electron/api/routes/coding-route-errors.ts @@ -10,6 +10,7 @@ const FIXED_CODING_ERROR_MESSAGES: Readonly> = { CODING_AGENT_NOT_FOUND: '当前伙伴不可用,请重新选择。', CODING_AGENT_ID_IMMUTABLE: '已有伙伴标识不能修改。', CODING_CONVERSATION_NOT_FOUND: '指定的对话不存在。', + CODING_CONVERSATION_ARCHIVED: '请先恢复已归档的对话,再继续操作。', CODING_CONVERSATION_REQUEST_INVALID: '对话请求无效,请检查输入。', CODING_FILE_NOT_FOUND: '指定的项目文件不存在。', CODING_FILE_REQUEST_INVALID: '文件请求无效,请检查输入。', diff --git a/electron/coding-projects/conversation-store.ts b/electron/coding-projects/conversation-store.ts index 806c8e9..15e39f9 100644 --- a/electron/coding-projects/conversation-store.ts +++ b/electron/coding-projects/conversation-store.ts @@ -10,6 +10,8 @@ export const CODING_CONVERSATIONS_PATH = '.makelore/conversations.json'; export interface CodingConversationV2 extends CodingConversationMetadata, ConversationModelState { piSessionId?: string; sessionKey?: string; + titleMode?: 'automatic' | 'manual'; + autoTitleSet?: boolean; } export interface CodingConversationFileV2 { @@ -22,6 +24,7 @@ export interface CreateCodingConversationInput { title: string; model: ProductModelRef | null; modelResolution: 'resolved' | 'required'; + titleMode?: 'automatic' | 'manual'; } export interface PiSessionBinding { @@ -83,6 +86,8 @@ function normalizeConversation(value: unknown): CodingConversationV2 { id, agentId, title, + titleMode: record.titleMode === 'automatic' ? 'automatic' : 'manual', + autoTitleSet: record.titleMode === 'automatic' ? record.autoTitleSet === true : true, ...normalizeModelState(record), ...(piSessionId ? { piSessionId } : {}), ...(sessionKey ? { sessionKey } : {}), @@ -139,7 +144,9 @@ export function createCodingConversationStore( file: CodingConversationFileV2; }>): Promise { const execute = async () => { - const change = await operation(await readNow()); + const current = await readNow(); + const change = await operation(current); + if (change.file === current) return change.result; const normalized = normalizeCodingConversationFileV2(change.file); await writer(conversationFilePath(projectPath), normalized); return change.result; @@ -167,6 +174,8 @@ export function createCodingConversationStore( id: createId(), agentId: input.agentId, title: input.title, + titleMode: input.titleMode ?? 'manual', + autoTitleSet: input.titleMode !== 'automatic', model: input.model, modelResolution: input.modelResolution, archivedAt: null, @@ -192,10 +201,10 @@ export function createCodingConversationStore( if (!current) throw new Error('Conversation does not exist'); const updated = normalizeConversation({ ...current, - ...(patch.title !== undefined ? { title: patch.title } : {}), + ...(patch.title !== undefined ? { title: patch.title, titleMode: 'manual', autoTitleSet: true } : {}), ...(patch.archivedAt !== undefined ? { archivedAt: patch.archivedAt } : {}), ...(patch.unread !== undefined ? { unread: patch.unread } : {}), - updatedAt: now(), + updatedAt: patch.archivedAt !== undefined || patch.unread !== undefined ? now() : current.updatedAt, }); return { result: updated, @@ -207,6 +216,16 @@ export function createCodingConversationStore( }); }, + async setFirstMessageTitle(conversationId: string, title: string) { + return await mutate(async (file) => { + const current = file.conversations.find((item) => item.id === conversationId); + if (!current || current.titleMode !== 'automatic' || current.autoTitleSet) return { result: null, file }; + const updated = { ...current, title, autoTitleSet: true }; + return { result: updated, file: { ...file, + conversations: file.conversations.map((item) => item.id === conversationId ? updated : item) } }; + }); + }, + async setModelState( conversationId: string, modelState: ConversationModelState, diff --git a/electron/coding-runtime/conversation-service.ts b/electron/coding-runtime/conversation-service.ts index 432845b..60f94e2 100644 --- a/electron/coding-runtime/conversation-service.ts +++ b/electron/coding-runtime/conversation-service.ts @@ -14,6 +14,7 @@ import type { PromptMode, } from './contracts'; import type { CodingConversationV2 } from '../coding-projects/conversation-store'; +import { firstMessageTitle } from './conversation-title'; import { CodingProjectService, CodingProjectServiceError, @@ -57,6 +58,7 @@ export interface CodingConversationServiceOptions { } export type CodingConversationStreamEvent = + | { type: 'conversation.metadata-changed'; projectId: string; conversationId: string } | { type: 'snapshot'; conversationId: string; @@ -125,7 +127,8 @@ async function persist(operation: () => Promise): Promise { } function publicConversation(conversation: CodingConversationV2): CodingConversationV2 { - const { piSessionId: _piSessionId, sessionKey: _sessionKey, ...safe } = conversation; + const { piSessionId: _piSessionId, sessionKey: _sessionKey, + titleMode: _titleMode, autoTitleSet: _autoTitleSet, ...safe } = conversation; return safe; } @@ -267,7 +270,46 @@ export class CodingConversationService { readonly projects: CodingProjectService, readonly runtime: CodingConversationRuntime, private readonly options: CodingConversationServiceOptions = {}, - ) {} + ) { + this.unsubscribeTitles = runtime.subscribe((event) => { + if (event.patch.op !== 'message.upsert') return; + const title = firstMessageTitle(event.patch.node); + if (!title) return; + const context = this.titleContexts.get(event.conversationId); + if (!context) return; + void this.setFirstMessageTitle(context, event.conversationId, title).catch(() => undefined); + }); + } + + private readonly titleContexts = new Map(); + private readonly metadataListeners = new Set<(event: CodingConversationStreamEvent) => void>(); + private readonly unsubscribeTitles: () => void; + + private async setFirstMessageTitle(project: { id: string; path: string }, id: string, title: string): Promise { + if (await this.projects.conversationStore(project.path).setFirstMessageTitle(id, title)) { + this.metadataChanged(project.id, id); + } + this.titleContexts.delete(id); + } + + dispose(): void { + this.unsubscribeTitles(); + this.titleContexts.clear(); + this.metadataListeners.clear(); + } + + private metadataChanged(projectId: string, conversationId: string): void { + for (const listener of this.metadataListeners) { + listener({ type: 'conversation.metadata-changed', projectId, conversationId }); + } + } + + private async assertUnarchived(conversationId: string): Promise { + const { conversation } = await this.projects.findActiveConversation(conversationId); + if (conversation.archivedAt) { + throw new CodingConversationServiceError(409, 'CODING_CONVERSATION_ARCHIVED', '请先恢复已归档的对话,再继续操作。'); + } + } async listConversations(projectId?: string): Promise { const project = projectId @@ -295,6 +337,7 @@ export class CodingConversationService { const created = await persist(() => this.projects.conversationStore(project.path).create({ agentId, title: requiredString(input.title, 'Conversation title', MAX_TITLE_LENGTH), + titleMode: input.title === '新对话' ? 'automatic' : 'manual', model: agent.model, modelResolution: agent.modelResolution, })); @@ -330,6 +373,7 @@ export class CodingConversationService { : {}), ...(typeof patch.unread === 'boolean' ? { unread: patch.unread } : {}), })); + this.metadataChanged(project.id, conversationId); return publicConversation(updated); } @@ -342,6 +386,7 @@ export class CodingConversationService { } await this.archiveSession(project.id, conversation.sessionKey); await persist(() => this.projects.conversationStore(project.path).delete(conversationId)); + this.titleContexts.delete(conversationId); this.prepareFlights.delete(conversationId); for (const key of [...this.acceptances.keys()]) { if (key.startsWith(`${conversationId}\u0000`)) this.acceptances.delete(key); @@ -406,6 +451,7 @@ export class CodingConversationService { const acceptanceState: AcceptanceRecord['state'] = { value: 'protected' }; const flight = (async (): Promise => { try { + await this.assertUnarchived(conversationId); await this.ensurePrepared(conversationId); const acceptance = await this.runtime.prompt({ conversationId, @@ -497,6 +543,7 @@ export class CodingConversationService { } async fork(sourceConversationId: string, sourceEntryId?: string): Promise { + await this.assertUnarchived(sourceConversationId); const entryId = requiredString(sourceEntryId, 'Fork source entry id', 256); const prepared = await this.ensurePrepared(sourceConversationId); let sourceSnapshot: ConversationSnapshot; @@ -524,6 +571,7 @@ export class CodingConversationService { const created = await persist(() => store.create({ agentId: source.conversation.agentId, title: `${source.conversation.title} (fork)`, + titleMode: 'manual', model: source.conversation.model, modelResolution: source.conversation.modelResolution, })); @@ -591,6 +639,10 @@ export class CodingConversationService { : requiredString(conversationId, 'Conversation id', 128); if (id) await this.projects.findActiveConversation(id); const queue = new PatchQueue(); + const metadataListener = (event: CodingConversationStreamEvent) => { + if (!id || event.conversationId === id) queue.push(event); + }; + this.metadataListeners.add(metadataListener); const batches = new PatchBatchScheduler((event) => queue.push(event), this.options); const cursors = new Map(); let initialize!: () => void; @@ -642,6 +694,7 @@ export class CodingConversationService { snapshots, events: queue, close: () => { + this.metadataListeners.delete(metadataListener); unsubscribe(); batches.close(); queue.close(); @@ -649,6 +702,7 @@ export class CodingConversationService { }; } catch (error) { initialize(); + this.metadataListeners.delete(metadataListener); unsubscribe(); batches.close(); queue.close(); @@ -662,6 +716,9 @@ export class CodingConversationService { if (prior) return prior; const flight = (async () => { const { project, conversation } = await this.projects.findActiveConversation(id); + if (conversation.titleMode === 'automatic' && !conversation.autoTitleSet) { + this.titleContexts.set(id, { id: project.id, path: project.path }); + } const config = await this.projects.getConfig(project.id); const agent = config.config.agents.find((candidate) => ( candidate.id === conversation.agentId && candidate.enabled && !candidate.archivedAt @@ -680,6 +737,11 @@ export class CodingConversationService { }, }; try { await this.runtime.prepare(prepared); } catch (error) { runtimeError(error); } + if (this.titleContexts.has(id)) { + const snapshot = await this.runtime.getSnapshot(id); + const title = snapshot.nodes.map(firstMessageTitle).find((candidate) => candidate !== null); + if (title) await this.setFirstMessageTitle(project, id, title).catch(() => undefined); + } return prepared; })().finally(() => { if (this.prepareFlights.get(id) === flight) this.prepareFlights.delete(id); diff --git a/electron/coding-runtime/conversation-title.ts b/electron/coding-runtime/conversation-title.ts new file mode 100644 index 0000000..eb16c78 --- /dev/null +++ b/electron/coding-runtime/conversation-title.ts @@ -0,0 +1,11 @@ +import type { ConversationNode } from './contracts'; + +/** Only real user messages name a conversation; no model call or tool output. */ +export function firstMessageTitle(node: ConversationNode): string | null { + if (node.kind !== 'message' || node.role !== 'user' || node.status !== 'complete') return null; + const text = node.blocks.flatMap((block) => block.kind === 'text' ? [block.text] : []).join('\n').trim(); + if (text.startsWith('/')) return null; + const firstLine = text.split(/\r?\n/)[0].replace(/\s+/g, ' ').trim(); + return [...firstLine].slice(0, 32).join('') + || (node.blocks.some((block) => block.kind === 'image') ? '附件对话' : null); +} diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 7dff2cf..ff6e9e8 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { toast } from 'sonner'; import { Bot, ChevronRight, @@ -43,6 +44,7 @@ import { AgentBrowserPanel } from './AgentBrowserPanel'; import { CodingChangesSummary } from './CodingChangesSummary'; import { CodingConversationSidebar } from './CodingConversationSidebar'; import { CodingConversationHeader } from './CodingConversationHeader'; +import { CodingConversationRenameDialog } from './CodingConversationRenameDialog'; import { CodingConversationTimeline } from './CodingConversationTimeline'; import { CodingInteractionPanel } from './CodingInteractionPanel'; import { CodingWelcomeHero } from './CodingWelcomeHero'; @@ -170,6 +172,12 @@ export function CodingChatPanel({ Record >({}); const [agentBrowserOpen, setAgentBrowserOpen] = useState(false); + const [archivedContext, setArchivedContext] = useState(null); + const [renameTarget, setRenameTarget] = useState<{ projectId: string; conversation: CodingConversationMetadata } | null>(null); + const [busyConversationIds, setBusyConversationIds] = useState>({}); + const archiveFlights = useRef(new Set()); + const showArchived = Boolean(activeProject && selectedAgentId + && archivedContext === `${activeProject.id}:${selectedAgentId}`); const appliedNavigationDraftRef = useRef(null); const automaticCreationKeyRef = useRef(null); const selectedConversationContextRef = useRef(null); @@ -185,7 +193,7 @@ export function CodingChatPanel({ const selectedConversation = conversations.find((conversation) => ( conversation.id === selectedConversationId && conversation.agentId === selectedAgent?.id - && !conversation.archivedAt + && (!conversation.archivedAt || showArchived) )) ?? null; const targetConversationId = selectedConversation?.id ?? null; const conversationMetadataError = activeProject && targetConversationId @@ -268,6 +276,8 @@ export function CodingChatPanel({ useEffect(() => { setAgentBrowserOpen(false); + setArchivedContext(null); + setRenameTarget(null); }, [activeProject?.id]); useEffect(() => () => disconnectEvents(), [disconnectEvents]); @@ -309,7 +319,7 @@ export function CodingChatPanel({ clearConversationSelection(); return; } - if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) { + if (selectedConversation?.agentId === selectedAgent.id) { const selectionContext = `${activeProject.id}:${selectedConversation.id}`; if (selectedConversationContextRef.current !== selectionContext) { void selectProjectConversation(activeProject.id, selectedConversation.id) @@ -317,6 +327,10 @@ export function CodingChatPanel({ } return; } + if (showArchived) { + clearConversationSelection(); + return; + } const next = newestConversation(conversations, selectedAgent.id); if (next) { markConversationUnread(next.id, false); @@ -325,6 +339,11 @@ export function CodingChatPanel({ return; } const creationKey = `${activeProject.id}:${selectedAgent.id}`; + if (conversations.some((conversation) => conversation.agentId === selectedAgent.id)) { + selectedConversationContextRef.current = null; + clearConversationSelection(); + return; + } if (automaticCreationKeyRef.current === creationKey) return; automaticCreationKeyRef.current = creationKey; void ensureConversation(selectedAgent.id) @@ -349,6 +368,7 @@ export function CodingChatPanel({ selectProjectConversation, selectedAgent, selectedConversation, + showArchived, ]); useEffect(() => { @@ -407,6 +427,7 @@ export function CodingChatPanel({ primeConversation(createLocalConversationSnapshot(projectId, conversation)); const current = codingWorkspaceStore.getState(); if (current.activeProjectId === projectId && current.selectedAgentId === agentId) { + setArchivedContext(null); await selectProjectConversation(projectId, conversation.id).catch(() => undefined); } }, [ @@ -418,6 +439,40 @@ export function CodingChatPanel({ selectedAgent, ]); + const handleArchiveConversation = async (conversation: CodingConversationMetadata, archived = !conversation.archivedAt) => { + if (!activeProject || archiveFlights.current.has(conversation.id)) return; + const projectId = activeProject.id; + const agentId = conversation.agentId; + const selectionAtStart = codingConversationStore.getState().selectedConversationId; + archiveFlights.current.add(conversation.id); + setBusyConversationIds((current) => ({ ...current, [conversation.id]: true })); + try { + const updated = await patchConversation(conversation.id, { archived }); + const current = codingWorkspaceStore.getState(); + if (current.activeProjectId !== projectId || current.selectedAgentId !== agentId) return; + if (!archived && codingConversationStore.getState().selectedConversationId === selectionAtStart) { + setArchivedContext(null); + handleSelectConversation(updated); + } + if (archived) { + const status = codingConversationStore.getState().summariesByConversationId[conversation.id]?.runStatus; + const active = status && ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(status); + toast.success(active ? '对话已归档,任务继续运行' : '对话已归档', { + action: { label: '撤销', onClick: () => { + if (codingWorkspaceStore.getState().activeProjectId === projectId) { + void handleArchiveConversation(updated, false); + } + } }, + }); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '更新对话失败'); + } finally { + archiveFlights.current.delete(conversation.id); + setBusyConversationIds((current) => { const next = { ...current }; delete next[conversation.id]; return next; }); + } + }; + const handleForkConversation = useCallback(async (sourceEntryId?: string) => { if (!activeProject || !selectedAgent || !targetConversationId) return; const sourceProjectId = activeProject.id; @@ -704,7 +759,13 @@ export function CodingChatPanel({ selectedAgentId={selectedAgent?.id ?? null} selectedConversationId={targetConversationId} creatingAgentIds={creatingAgentIds} + showArchived={showArchived} + busyConversationIds={busyConversationIds} + onToggleArchived={() => setArchivedContext(showArchived ? null : `${activeProject?.id}:${selectedAgentId}`)} + onRenameConversation={(conversation) => { if (activeProject) setRenameTarget({ projectId: activeProject.id, conversation }); }} + onArchiveConversation={(conversation) => void handleArchiveConversation(conversation)} onSelectAgent={(agentId) => { + setArchivedContext(null); clearConversationSelection(); selectAgent(agentId); }} @@ -712,6 +773,12 @@ export function CodingChatPanel({ onCreateConversation={() => void handleCreateConversation()} onOpenProjectSettings={onOpenProjectSettings} /> + {renameTarget && renameTarget.projectId === activeProject?.id && ( + { await patchConversation(renameTarget.conversation.id, { title }); }} + onClose={() => setRenameTarget(null)} /> + )}
{ + onFork={selectedConversation?.archivedAt ? undefined : (sourceEntryId) => { void handleForkConversation(sourceEntryId).catch((error) => { if (!draftKey) return; setSubmissionErrors((current) => ({ @@ -789,7 +856,11 @@ export function CodingChatPanel({
) : ( -
+
+ {conversations.some((conversation) => conversation.agentId === selectedAgentId) + ? showArchived ? '选择一条已归档会话查看' : '没有最近会话,可新建对话或查看归档' + : null} +
)} {targetConversationId && ( @@ -809,7 +880,13 @@ export function CodingChatPanel({ /> )} - + {running ? '此对话已归档,任务仍在运行。' : '此对话已归档,恢复后可继续聊天。'} + +
+ ) : { if (targetConversationId) await loadConversationSnapshot(targetConversationId, 'silent'); }} - /> + />} (null); const [actionError, setActionError] = useState(null); const [renameOpen, setRenameOpen] = useState(false); - const [titleDraft, setTitleDraft] = useState(conversation?.title ?? ''); const runStatus = snapshot?.run.status ?? 'preparing'; const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); @@ -122,7 +113,6 @@ export function CodingConversationHeader({ className="no-drag group flex max-w-full items-center gap-1.5 rounded-md text-left" disabled={!conversation} onClick={() => { - setTitleDraft(conversation?.title ?? ''); setRenameOpen(true); }} > @@ -204,27 +194,8 @@ export function CodingConversationHeader({

)} - - - - 修改对话标题 - 标题只影响当前对话,不会改动智能体名称。 - - setTitleDraft(event.target.value)} /> - - - - - - + {renameOpen && conversation && setRenameOpen(false)} />} ); } diff --git a/src/pages/Chat/CodingConversationRenameDialog.tsx b/src/pages/Chat/CodingConversationRenameDialog.tsx new file mode 100644 index 0000000..3691a7f --- /dev/null +++ b/src/pages/Chat/CodingConversationRenameDialog.tsx @@ -0,0 +1,46 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; + +export function CodingConversationRenameDialog({ title, onSave, onClose }: { + title: string; + onSave(title: string): Promise; + onClose(): void; +}) { + const [draft, setDraft] = useState(title); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + return ( + { if (!open && !saving) onClose(); }}> + { + event.preventDefault(); + const input = document.getElementById('coding-rename-title') as HTMLInputElement | null; + input?.focus(); + input?.select(); + }}> +
{ + event.preventDefault(); + if (!draft.trim() || saving) return; + setSaving(true); + setError(null); + void onSave(draft.trim()).then(onClose).catch((reason) => { + setError(reason instanceof Error ? reason.message : String(reason)); + }).finally(() => setSaving(false)); + }}> + + 修改对话标题 + 手动命名后,标题不会再自动更新。 + + setDraft(event.target.value)} /> + {error &&

{error}

} + + + + +
+
+
+ ); +} diff --git a/src/pages/Chat/CodingConversationSidebar.tsx b/src/pages/Chat/CodingConversationSidebar.tsx index cd6d68b..7c9e5d9 100644 --- a/src/pages/Chat/CodingConversationSidebar.tsx +++ b/src/pages/Chat/CodingConversationSidebar.tsx @@ -5,7 +5,12 @@ import { MessageSquarePlus, Pin, Settings2, + Archive, + MoreHorizontal, + Pencil, + ArchiveRestore, } from 'lucide-react'; +import * as DropdownMenu from '@radix-ui/react-dropdown-menu'; import { Button } from '@/components/ui/button'; import { getAgentAvatarSrc } from '@/lib/agent-avatars'; import { cn } from '@/lib/utils'; @@ -47,6 +52,11 @@ export function CodingConversationSidebar({ onSelectConversation, onCreateConversation, onOpenProjectSettings, + showArchived = false, + onToggleArchived, + onRenameConversation, + onArchiveConversation, + busyConversationIds = {}, }: { projectName?: string; agents: CodingProjectAgent[]; @@ -59,6 +69,11 @@ export function CodingConversationSidebar({ onSelectConversation(conversation: CodingConversationMetadata): void; onCreateConversation(): void; onOpenProjectSettings?(): void; + showArchived?: boolean; + onToggleArchived?(): void; + onRenameConversation?(conversation: CodingConversationMetadata): void; + onArchiveConversation?(conversation: CodingConversationMetadata): void; + busyConversationIds?: Record; }) { const sidebarCollapsed = useSettingsStore((state) => state.sidebarCollapsed); const platform = typeof window === 'undefined' ? undefined : window.electron?.platform; @@ -113,13 +128,16 @@ export function CodingConversationSidebar({
{agents.map((agent) => { const selected = agent.id === selectedAgentId; - const agentConversations = conversations - .filter((conversation) => conversation.agentId === agent.id && !conversation.archivedAt) - .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt)); - const unreadCount = agentConversations.filter((conversation) => ( + const allAgentConversations = conversations.filter((conversation) => conversation.agentId === agent.id); + const archivedCount = allAgentConversations.filter((conversation) => conversation.archivedAt).length; + const agentConversations = allAgentConversations + .filter((conversation) => Boolean(conversation.archivedAt) === showArchived) + .sort((left, right) => (showArchived ? right.archivedAt! : right.updatedAt) + .localeCompare(showArchived ? left.archivedAt! : left.updatedAt)); + const unreadCount = allAgentConversations.filter((conversation) => ( conversationSummaries[conversation.id]?.unread ?? conversation.unread )).length; - const running = agentConversations.some((conversation) => { + const running = allAgentConversations.some((conversation) => { const runStatus = conversationSummaries[conversation.id]?.runStatus; return runStatus ? ACTIVE_RUN_STATUSES.has(runStatus) : false; }); @@ -187,8 +205,15 @@ export function CodingConversationSidebar({ >

- 最近对话 + {showArchived ? '已归档' : '最近对话'}

+
+ +
@@ -213,19 +239,19 @@ export function CodingConversationSidebar({ : false; const conversationSelected = selectedConversationId === conversation.id; return ( - + + + + + + + + onRenameConversation?.(conversation)}> + 重命名 + + onArchiveConversation?.(conversation)}> + {conversation.archivedAt ? : } + {conversation.archivedAt ? '恢复到最近对话' : conversationRunning ? '归档(任务继续运行)' : '归档'} + + + + +
); })} - {agentConversations.length === 0 && ( + {agentConversations.length === 0 && showArchived && ( +

没有已归档会话

+ )} + {agentConversations.length === 0 && !showArchived && ( )}
diff --git a/src/stores/coding-conversations.ts b/src/stores/coding-conversations.ts index 8f3301a..a7682ce 100644 --- a/src/stores/coding-conversations.ts +++ b/src/stores/coding-conversations.ts @@ -8,6 +8,7 @@ import { } from '../../shared/coding-conversation-reducer'; import { AppError } from '@/lib/error-model'; import { queueCodingConversationSessionSync } from '@/lib/agent-session-sync'; +import { codingWorkspaceStore } from './coding-workspace'; import { getCodingConversationSnapshot, openCodingConversationEvents, @@ -660,8 +661,20 @@ export function createCodingConversationStore( set({ connectionState: 'error', globalError: 'Conversation 事件更新无法读取。' }); } }); + source.addEventListener('conversation.metadata-changed', (event) => { + if (eventSource !== source) return; + try { + const metadata = parse<{ projectId: string }>(event); + if (typeof metadata.projectId === 'string') { + void codingWorkspaceStore.getState().refreshConversations(metadata.projectId).catch(() => undefined); + } + } catch { /* Reconnection refreshes authoritative metadata. */ } + }); source.onopen = () => { - if (eventSource === source) set({ connectionState: 'live', globalError: null }); + if (eventSource !== source) return; + set({ connectionState: 'live', globalError: null }); + const workspace = codingWorkspaceStore.getState(); + if (workspace.activeProjectId) void workspace.refreshConversations(workspace.activeProjectId).catch(() => undefined); }; source.onerror = () => { if (eventSource === source) { diff --git a/src/stores/coding-workspace.ts b/src/stores/coding-workspace.ts index 6486e37..99b39ab 100644 --- a/src/stores/coding-workspace.ts +++ b/src/stores/coding-workspace.ts @@ -61,6 +61,7 @@ export interface CodingWorkspaceState { conversationErrorsByProjectId: Record>; creatingAgentIds: Record; load(): Promise; + refreshConversations(projectId: string): Promise; openProject(projectPath: string): Promise; createProject(input: { projectPath?: string; @@ -139,6 +140,9 @@ export function createCodingWorkspaceStore( const conversationFlights = new Map>(); let loadFlight: Promise | null = null; let loadGeneration = 0; + let metadataRevision = 0; + let metadataTail = Promise.resolve(); + const refreshFlights = new Map }>(); return createStore((set, get) => ({ projects: [], @@ -152,6 +156,30 @@ export function createCodingWorkspaceStore( conversationErrorsByProjectId: {}, creatingAgentIds: {}, + async refreshConversations(projectId) { + if (get().activeProjectId !== projectId) return; + const existing = refreshFlights.get(projectId); + if (existing) { + existing.dirty = true; + return await existing.promise; + } + const refresh = { dirty: true, promise: Promise.resolve() }; + refresh.promise = (async () => { + while (refresh.dirty && get().activeProjectId === projectId) { + refresh.dirty = false; + await metadataTail; + const revision = metadataRevision; + const generation = loadGeneration; + const conversations = await deps.listConversations(projectId); + if (get().activeProjectId !== projectId || generation !== loadGeneration) return; + if (revision !== metadataRevision) { refresh.dirty = true; continue; } + if (!refresh.dirty) set({ conversations }); + } + })().finally(() => { refreshFlights.delete(projectId); }); + refreshFlights.set(projectId, refresh); + return await refresh.promise; + }, + async load() { if (loadFlight) return await loadFlight; const generation = ++loadGeneration; @@ -175,6 +203,8 @@ export function createCodingWorkspaceStore( }); return; } + if (get().activeProjectId === activeProject.id) await metadataTail; + const revision = metadataRevision; const [snapshot, conversations] = await Promise.all([ deps.getConfig(activeProject.id), deps.listConversations(activeProject.id), @@ -186,11 +216,13 @@ export function createCodingWorkspaceStore( activeProjectId: activeProject.id, activeProject: snapshot.project, config: snapshot.config, - conversations, + conversations: revision === metadataRevision || get().activeProjectId !== activeProject.id + ? conversations : get().conversations, selectedAgentId: currentAgentId ?? firstEnabledAgent(snapshot.config)?.id ?? null, loadState: 'ready', error: null, }); + if (revision !== metadataRevision) void get().refreshConversations(activeProject.id).catch(() => undefined); }) .catch((error) => { if (generation !== loadGeneration) return; @@ -259,6 +291,7 @@ export function createCodingWorkspaceStore( agentId, title: '新对话', }).then((conversation) => { + metadataRevision += 1; if (get().activeProjectId !== state.activeProject?.id) return conversation; set((current) => ({ conversations: [ @@ -285,6 +318,9 @@ export function createCodingWorkspaceStore( async patchConversation(conversationId, patch) { const sourceProjectId = get().activeProjectId; if (!sourceProjectId) throw new Error('当前没有可用的项目。'); + metadataRevision += 1; + const flight = metadataTail.then(() => deps.patchConversation(conversationId, patch)); + metadataTail = flight.then(() => undefined, () => undefined); set((current) => ({ conversationErrorsByProjectId: withConversationError( current.conversationErrorsByProjectId, @@ -294,10 +330,11 @@ export function createCodingWorkspaceStore( ), })); try { - const conversation = await deps.patchConversation(conversationId, patch); + const conversation = await flight; get().upsertConversation(sourceProjectId, conversation); return conversation; } catch (error) { + metadataRevision += 1; const message = error instanceof Error ? error.message : String(error); set((current) => ({ conversationErrorsByProjectId: withConversationError( @@ -312,6 +349,7 @@ export function createCodingWorkspaceStore( }, upsertConversation(projectId, conversation) { + metadataRevision += 1; if (get().activeProjectId !== projectId) return; set((current) => ({ conversations: [ diff --git a/tests/e2e/pi-coding-first-chat.spec.ts b/tests/e2e/pi-coding-first-chat.spec.ts index 3825130..ba90d44 100644 --- a/tests/e2e/pi-coding-first-chat.spec.ts +++ b/tests/e2e/pi-coding-first-chat.spec.ts @@ -109,6 +109,7 @@ async function installCodingFirstChatHost( }; mainGlobal.__makelorePiFirstChatE2E = state; const now = '2026-08-24T00:00:00.000Z'; + const metadata = new Map>(); const project = { id: 'project-pi-first-chat', name: 'PI first chat', @@ -545,11 +546,11 @@ async function installCodingFirstChatHost( } if (path === `/api/coding/projects/conversations?projectId=${project.id}`) { return respond({ - conversations: featureComplete + conversations: (featureComplete ? [conversation, secondConversation, historyConversation] : state.conversationCreated ? [conversation] - : [], + : []).map((item) => ({ ...item, ...metadata.get(item.id) })), }); } if (path.startsWith('/api/agent-browser/state?')) { @@ -712,7 +713,14 @@ async function installCodingFirstChatHost( return respond({ conversation: { ...conversation, id: 'conversation-forked', title: 'Feature UI branch' } }, 201); } if (/^\/api\/coding\/conversations\/[^/]+$/.test(path) && method === 'PATCH') { - return respond({ conversation: { ...conversation, ...(body?.title ? { title: body.title } : {}), unread: body?.unread === true, archivedAt: body?.archived === true ? now : null } }); + const target = [conversation, secondConversation, historyConversation].find((item) => path.endsWith('/' + item.id)) ?? conversation; + const updated = { ...target, ...metadata.get(target.id), + ...(body?.title ? { title: body.title } : {}), + ...(typeof body?.unread === 'boolean' ? { unread: body.unread } : {}), + ...(typeof body?.archived === 'boolean' ? { archivedAt: body.archived ? now : null } : {}), + }; + metadata.set(target.id, updated); + return respond({ conversation: updated }); } if (/^\/api\/coding\/interactions\/[^/]+\/respond$/.test(path) && method === 'POST') { state.interactionAnswered = true; @@ -770,6 +778,54 @@ async function settleSnapshot(electronApp: ElectronApplication): Promise { }); } +test('conversation menus rename, archive and restore without selecting or stopping the target', 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); + 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 sidebar = page.getByTestId('coding-conversation-sidebar'); + const header = page.getByTestId('coding-conversation-header'); + await expect(header).toContainText('新对话'); + await sidebar.getByRole('button', { name: '对话操作:Second Conversation' }).focus(); + await page.keyboard.press('Enter'); + await page.getByRole('menuitem', { name: '重命名', exact: true }).click(); + const title = page.getByRole('textbox', { name: '对话标题' }); + await expect(title).toHaveValue('Second Conversation'); + await title.fill('重命名后的会话'); + await title.press('Enter'); + await expect(page.getByRole('dialog')).toHaveCount(0); + await expect(header).toContainText('新对话'); + await sidebar.getByRole('button', { name: '对话操作:重命名后的会话' }).focus(); + 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: '已归档(1)' }).click(); + 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.getByText('最近对话', { 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' }); + } finally { await releaseSnapshot(electronApp); } +}); + test('managed capabilities expose native xhigh and block unsupported image input', async ({ launchElectronApp }) => { const electronApp = await launchElectronApp({ skipSetup: true }); let page = await getStableWindow(electronApp); diff --git a/tests/unit/coding-chat-panel.test.tsx b/tests/unit/coding-chat-panel.test.tsx index dcb5e0f..0c83333 100644 --- a/tests/unit/coding-chat-panel.test.tsx +++ b/tests/unit/coding-chat-panel.test.tsx @@ -205,6 +205,60 @@ describe('CodingChatPanel first Conversation', () => { vi.resetModules(); }); + it('archives the last conversation without creating another, then restores the same running conversation', async () => { + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ project, config }); + projectApi.conversations.mockResolvedValue([conversation]); + projectApi.patch.mockImplementation(async (_id, patch) => ({ + ...conversation, archivedAt: patch.archived ? '2026-09-20T00:00:00Z' : 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), + run: { status: 'running', runId: 'run-1' } }); + const { CodingChatPanel } = await import('@/pages/Chat/CodingChatPanel'); + const { codingConversationStore } = await import('@/stores/coding-conversations'); + render(); + const menu = await screen.findByRole('button', { name: '对话操作:新对话' }); + await waitFor(() => expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id)); + fireEvent.keyDown(menu, { key: 'Enter' }); + fireEvent.click(await screen.findByRole('menuitem', { name: '归档(任务继续运行)' })); + await screen.findByText('没有最近会话,可新建对话或查看归档'); + expect(projectApi.create).not.toHaveBeenCalled(); + expect(conversationApi.abort).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: '已归档(1)' })); + fireEvent.click(within(screen.getByTestId('coding-conversation-sidebar')).getByRole('button', { name: '新对话', exact: true })); + await screen.findByText('此对话已归档,任务仍在运行。'); + expect(screen.queryByRole('button', { name: '发送', exact: true })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: '恢复对话' })); + await waitFor(() => expect(screen.queryByRole('button', { name: '恢复对话' })).not.toBeInTheDocument()); + expect(codingConversationStore.getState().selectedConversationId).toBe(conversation.id); + expect(projectApi.patch).toHaveBeenLastCalledWith(conversation.id, { archived: false }); + }); + + it('renames an unselected conversation from its menu without preparing that conversation', async () => { + const other = { ...conversation, id: 'unselected', title: '旧标题', updatedAt: '2025-01-01T00:00:00Z' }; + projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); + projectApi.config.mockResolvedValue({ project, config }); + projectApi.conversations.mockResolvedValue([conversation, other]); + projectApi.patch.mockImplementation(async (_id, patch) => ({ ...other, ...patch })); + 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 } = await import('@/pages/Chat/CodingChatPanel'); + render(); + fireEvent.keyDown(await screen.findByRole('button', { name: '对话操作:旧标题' }), { key: 'Enter' }); + fireEvent.click(await screen.findByRole('menuitem', { name: '重命名' })); + const input = screen.getByRole('textbox', { name: '对话标题' }); + expect(input).toHaveValue('旧标题'); + expect(input).toHaveAttribute('maxlength', '200'); + fireEvent.change(input, { target: { value: ' 手动新标题 ' } }); + fireEvent.submit(input.closest('form')!); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + expect(projectApi.patch).toHaveBeenCalledWith(other.id, { title: '手动新标题' }); + expect(conversationApi.snapshot).not.toHaveBeenCalledWith(other.id); + }); + it('keeps a project with no Agent accessible and offers optional Agent setup', async () => { const openProjectSettings = vi.fn(); projectApi.list.mockResolvedValue({ projects: [project], activeProjectId: project.id }); diff --git a/tests/unit/coding-conversation-title.test.ts b/tests/unit/coding-conversation-title.test.ts new file mode 100644 index 0000000..ed0361f --- /dev/null +++ b/tests/unit/coding-conversation-title.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { firstMessageTitle } from '../../electron/coding-runtime/conversation-title'; +import type { ConversationMessageNode } from '../../shared/coding-conversation-contracts'; + +function message(text: string, overrides: Partial = {}): ConversationMessageNode { + return { kind: 'message', id: 'user', role: 'user', status: 'complete', + blocks: [{ kind: 'text', id: 'text', status: 'complete', text }], ...overrides }; +} + +describe('first user message title', () => { + it('uses the first line, collapses whitespace, and bounds long text by characters', () => { + expect(firstMessageTitle(message(' 做一个 登录页\n支持短信登录'))).toBe('做一个 登录页'); + expect(firstMessageTitle(message('🦊'.repeat(40)))).toBe('🦊'.repeat(32)); + }); + it('ignores optimistic, assistant, command and empty content', () => { + expect(firstMessageTitle(message('draft', { status: 'optimistic' }))).toBeNull(); + expect(firstMessageTitle(message('reply', { role: 'assistant' }))).toBeNull(); + expect(firstMessageTitle(message('/compact'))).toBeNull(); + expect(firstMessageTitle(message(' '))).toBeNull(); + }); + it('names image-only conversations without inspecting the attachment', () => { + expect(firstMessageTitle(message('', { blocks: [{ kind: 'image', id: 'img', attachmentId: 'attachment', mime: 'image/png' }] }))).toBe('附件对话'); + }); +}); diff --git a/tests/unit/coding-conversations-store.test.tsx b/tests/unit/coding-conversations-store.test.tsx index 91e5b0c..d066061 100644 --- a/tests/unit/coding-conversations-store.test.tsx +++ b/tests/unit/coding-conversations-store.test.tsx @@ -2,6 +2,7 @@ import { act, renderHook, waitFor } from '@testing-library/react'; import { readFile } from 'node:fs/promises'; import { fileURLToPath } from 'node:url'; import { useStore } from 'zustand'; +import { codingWorkspaceStore } from '@/stores/coding-workspace'; import { describe, expect, it, vi } from 'vitest'; import { AppError } from '@/lib/error-model'; import { @@ -29,7 +30,7 @@ class FakeEventSource { this.listeners.set(type, listeners); } - emit(type: 'snapshot' | 'patch-batch', value: unknown): void { + emit(type: 'snapshot' | 'patch-batch' | 'conversation.metadata-changed', value: unknown): void { const event = new MessageEvent(type, { data: JSON.stringify(value) }); for (const listener of this.listeners.get(type) ?? []) { if (typeof listener === 'function') listener(event); @@ -138,6 +139,27 @@ function ids() { } describe('coding Conversation store', () => { + it('refreshes only metadata on title events and EventSource reconnect without preparing workers', async () => { + const previous = codingWorkspaceStore.getState(); + const refresh = vi.fn(async () => undefined); + codingWorkspaceStore.setState({ activeProjectId: 'project-metadata', refreshConversations: refresh }); + const source = new FakeEventSource(); + const getSnapshot = vi.fn(); + const store = createCodingConversationStore({ openEvents: async () => source as unknown as EventSource, getSnapshot }); + try { + await store.getState().connectEvents(); + source.emit('conversation.metadata-changed', { projectId: 'project-metadata', conversationId: 'hidden' }); + source.open(); + source.fail(); + source.open(); + expect(refresh.mock.calls).toEqual([['project-metadata'], ['project-metadata'], ['project-metadata']]); + expect(getSnapshot).not.toHaveBeenCalled(); + } finally { + store.getState().disconnectEvents(); + codingWorkspaceStore.setState(previous, true); + } + }); + it('queues observation sync only for a live completed ordinary prompt', () => { const queueSettledSessionSync = vi.fn(); const store = createCodingConversationStore({ diff --git a/tests/unit/coding-core-routes.test.ts b/tests/unit/coding-core-routes.test.ts index 585d073..3c12e18 100644 --- a/tests/unit/coding-core-routes.test.ts +++ b/tests/unit/coding-core-routes.test.ts @@ -100,6 +100,60 @@ async function createConversation( } describe('PI-100 coding core Host contract', () => { + it('names new conversations from the first real user event without a Renderer stream', async () => { + const result = await setup(); + result.conversations.dispose(); + let publish!: (event: ConversationPatchEnvelope) => void; + vi.spyOn(result.runtime, 'subscribe').mockImplementation((listener) => { publish = listener; return () => undefined; }); + const service = new CodingConversationService(result.projects, result.runtime); + const conversation = await service.createConversation({ agentId: 'builder', title: '新对话' }); + await service.getSnapshot(conversation.id); + const sendUser = (text: string, status: 'complete' | 'optimistic' = 'complete') => publish({ + conversationId: conversation.id, workerGeneration: 1, seq: 1, at: 1, + patch: { op: 'message.upsert', node: { kind: 'message', id: text, role: 'user', status, + blocks: [{ kind: 'text', id: text, text, status: 'complete' }] } }, + }); + sendUser('未被接受的草稿', 'optimistic'); + expect((await service.getConversation(conversation.id)).title).toBe('新对话'); + sendUser('修复登录页\n这是第二行'); + sendUser('后续消息'); + await vi.waitFor(async () => expect((await service.getConversation(conversation.id)).title).toBe('修复登录页')); + await service.patchConversation(conversation.id, { title: '我的标题' }); + sendUser('再次更新'); + expect((await service.getConversation(conversation.id)).title).toBe('我的标题'); + expect(await service.getConversation(conversation.id)).not.toHaveProperty('titleMode'); + service.dispose(); + }); + + it('archives without stopping work, emits metadata, and guards only new prompts and forks', async () => { + const result = await setup(); + const conversation = await createConversation(result.conversations); + await result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'first', + mode: 'prompt', text: 'Build' }); + const dispose = vi.spyOn(result.runtime, 'dispose'); + const abort = vi.spyOn(result.runtime, 'abort'); + const stream = await result.conversations.openEventStream(); + await result.conversations.patchConversation(conversation.id, { archived: true }); + expect((await stream.events[Symbol.asyncIterator]().next()).value).toEqual({ + type: 'conversation.metadata-changed', projectId: 'project-a', conversationId: conversation.id, + }); + expect(dispose).not.toHaveBeenCalled(); + expect(abort).not.toHaveBeenCalled(); + expect((await result.runtime.getSnapshot(conversation.id)).run.status).toBe('running'); + for (const mode of ['prompt', 'steer', 'follow-up']) { + await expect(result.conversations.acceptPrompt({ conversationId: conversation.id, + clientRequestId: mode, mode, text: 'New work' })).rejects.toMatchObject({ code: 'CODING_CONVERSATION_ARCHIVED' }); + } + await expect(result.conversations.fork(conversation.id, 'user-entry')).rejects.toMatchObject({ code: 'CODING_CONVERSATION_ARCHIVED' }); + await expect(result.conversations.acceptPrompt({ conversationId: conversation.id, clientRequestId: 'first', + mode: 'prompt', text: 'Build' })).resolves.toMatchObject({ accepted: true }); + await result.conversations.abort(conversation.id); + expect(abort).toHaveBeenCalledOnce(); + await result.conversations.patchConversation(conversation.id, { archived: false }); + expect((await result.conversations.getConversation(conversation.id)).archivedAt).toBeNull(); + stream.close(); + }); + it('uses one vendor-neutral Main composition without spawning on create', async () => { const projectPath = await mkdtemp(path.join(tmpdir(), 'makelore-pi-composition-project-')); const userDataDir = await mkdtemp(path.join(tmpdir(), 'makelore-pi-composition-user-')); diff --git a/tests/unit/coding-projects-schema-v2.test.ts b/tests/unit/coding-projects-schema-v2.test.ts index f6b1a9f..9d4e79a 100644 --- a/tests/unit/coding-projects-schema-v2.test.ts +++ b/tests/unit/coding-projects-schema-v2.test.ts @@ -55,6 +55,38 @@ afterEach(async () => { }); describe('coding project schema v2', () => { + it('assigns the first title once without moving the conversation, and manual names win', async () => { + const root = await makeProjectPath(); + let timestamp = NOW; + const store = createCodingConversationStore(root, { now: () => timestamp }); + const created = await store.create({ agentId: 'builder', title: '新对话', titleMode: 'automatic', model: MODEL, modelResolution: 'resolved' }); + timestamp = NEXT; + await store.setFirstMessageTitle(created.id, '制作登录页面'); + await store.setFirstMessageTitle(created.id, '第二句话不能覆盖'); + expect(await store.get(created.id)).toMatchObject({ title: '制作登录页面', updatedAt: NOW, autoTitleSet: true }); + await store.patchMetadata(created.id, { title: '新对话' }); + await store.setFirstMessageTitle(created.id, '也不能覆盖手动占位标题'); + expect(await store.get(created.id)).toMatchObject({ title: '新对话', titleMode: 'manual', updatedAt: NOW }); + const archived = await store.patchMetadata(created.id, { archivedAt: NEXT }); + expect(archived).toMatchObject({ archivedAt: NEXT, updatedAt: NEXT }); + const restored = await store.patchMetadata(created.id, { archivedAt: null }); + expect(restored.id).toBe(created.id); + expect(restored.title).toBe('新对话'); + }); + + it('leaves historical titles alone and never resurrects a deleted conversation', async () => { + const root = await makeProjectPath(); + const store = createCodingConversationStore(root); + const created = await store.create({ agentId: 'builder', title: '旧标题', model: MODEL, modelResolution: 'resolved' }); + const { titleMode: _mode, autoTitleSet: _set, ...legacy } = created; + await atomicWriteJson(path.join(root, '.makelore/conversations.json'), { schemaVersion: 2, conversations: [legacy] }); + expect(await store.setFirstMessageTitle(created.id, '替换标题')).toBeNull(); + expect(await store.get(created.id)).toMatchObject({ title: '旧标题', titleMode: 'manual', autoTitleSet: true }); + await store.delete(created.id); + expect(await store.setFirstMessageTitle(created.id, '不能复活')).toBeNull(); + expect((await store.read()).conversations).toEqual([]); + }); + it('accepts the native max thinking level in a project model reference', () => { expect(normalizeProductModelRef({ accountId: 'account-local', diff --git a/tests/unit/coding-workspace-store.test.ts b/tests/unit/coding-workspace-store.test.ts index cd57ad2..8cd03a3 100644 --- a/tests/unit/coding-workspace-store.test.ts +++ b/tests/unit/coding-workspace-store.test.ts @@ -79,6 +79,47 @@ function deferred() { } describe('coding workspace store', () => { + it('discards a stale list read and refreshes after a manual rename', async () => { + const old = conversation('conversation-a', 'agent-a'); + const stale = deferred(); + const latest = { ...old, title: '手动标题' }; + const listConversations = vi.fn().mockResolvedValueOnce([old]) + .mockReturnValueOnce(stale.promise).mockResolvedValue([latest]); + const store = createCodingWorkspaceStore({ + listProjects: async () => ({ projects: [project], activeProjectId: project.id }), + getConfig: async () => ({ project, config: config([agent('agent-a')]) }), + listConversations, patchConversation: async () => latest, + }); + await store.getState().load(); + const refresh = store.getState().refreshConversations(project.id); + await vi.waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2)); + await store.getState().patchConversation(old.id, { title: latest.title }); + stale.resolve([old]); + await refresh; + expect(store.getState().conversations[0].title).toBe(latest.title); + expect(listConversations).toHaveBeenCalledTimes(3); + expect(store.getState().loadState).toBe('ready'); + }); + + it('does a follow-up read when metadata changes during an in-flight refresh', async () => { + const old = conversation('conversation-a', 'agent-a'); + const stale = deferred(); + const latest = { ...old, archivedAt: '2026-09-20T00:00:00Z' }; + const listConversations = vi.fn().mockResolvedValueOnce([old]) + .mockReturnValueOnce(stale.promise).mockResolvedValue([latest]); + const store = createCodingWorkspaceStore({ + listProjects: async () => ({ projects: [project], activeProjectId: project.id }), + getConfig: async () => ({ project, config: config([agent('agent-a')]) }), listConversations, + }); + await store.getState().load(); + const refresh = store.getState().refreshConversations(project.id); + await vi.waitFor(() => expect(listConversations).toHaveBeenCalledTimes(2)); + const followup = store.getState().refreshConversations(project.id); + stale.resolve([old]); + await Promise.all([refresh, followup]); + expect(store.getState().conversations).toEqual([latest]); + }); + it('loads local project metadata and selects the pinned Agent without touching runtime APIs', async () => { const listProjects = vi.fn(async () => ({ projects: [project], activeProjectId: project.id })); const getConfig = vi.fn(async () => ({ diff --git a/tests/unit/pi-managed-user-entry-fork-real.test.ts b/tests/unit/pi-managed-user-entry-fork-real.test.ts index b114a5f..d35a780 100644 --- a/tests/unit/pi-managed-user-entry-fork-real.test.ts +++ b/tests/unit/pi-managed-user-entry-fork-real.test.ts @@ -82,7 +82,8 @@ describe('real managed Pi user-entry fork', () => { }); const source = await store.create({ agentId: 'agent-real-fork', - title: 'Persisted source', + title: '新对话', + titleMode: 'automatic', model, modelResolution: 'resolved', }); @@ -185,6 +186,7 @@ describe('real managed Pi user-entry fork', () => { try { const sourceSnapshot = await service.getSnapshot(source.id); + expect((await service.getConversation(source.id)).title).toBe('Persisted user fork source'); expect(processOptions[0]?.sessionDir).toBe(sessionDirectory); expect(processOptions[0]?.cwd).toBe(project.path); expect(sourceSnapshot.nodes).toEqual(expect.arrayContaining([ @@ -204,6 +206,7 @@ describe('real managed Pi user-entry fork', () => { store.get(forked.id), ]); expect(forked.agentId).toBe(source.agentId); + expect(forkedBinding).toMatchObject({ titleMode: 'manual', autoTitleSet: true }); expect(forkedSnapshot).toMatchObject({ conversation: { id: forked.id, agentId: source.agentId }, nodes: [], @@ -221,6 +224,7 @@ describe('real managed Pi user-entry fork', () => { expect(forkedBinding?.sessionKey).not.toBe(sourceBinding?.sessionKey); expect(runtime.getDiagnostics().workers).toHaveLength(2); } finally { + service.dispose(); await runtime.shutdown(); await extensionHost.close(); }