From 863f005203b64cb163b6f61a7c352c0d30f8bf67 Mon Sep 17 00:00:00 2001 From: brother7 <7brother7@gmail.com> Date: Mon, 24 Aug 2026 08:59:16 +0800 Subject: [PATCH] feat(coding): complete PI conversation UI --- .../tasks/20260824-pi-feature-ui-9d4e7a31.md | 126 ++++++++ README.md | 7 +- src/lib/coding-conversations.ts | 90 ++++++ src/lib/coding-model-options.ts | 58 ++++ src/lib/coding-projects.ts | 18 ++ src/pages/Chat/CodingAttachmentPreview.tsx | 66 +++++ src/pages/Chat/CodingChatPanel.tsx | 181 +++++++++--- src/pages/Chat/CodingComposer.tsx | 44 ++- src/pages/Chat/CodingConversationHeader.tsx | 250 ++++++++++++++++ src/pages/Chat/CodingConversationTimeline.tsx | 276 +++++++++++++----- src/pages/Chat/CodingInteractionPanel.tsx | 147 ++++++++++ src/pages/Chat/CodingWorkspaceInspector.tsx | 253 ++++++++++++++++ src/stores/coding-workspace.ts | 32 ++ src/types/coding-conversation.ts | 5 + tests/e2e/pi-coding-first-chat.spec.ts | 177 ++++++++++- tests/unit/coding-chat-panel.test.tsx | 20 ++ .../coding-conversation-timeline.test.tsx | 101 +++++++ .../unit/coding-conversations-facade.test.ts | 58 ++++ tests/unit/coding-feature-ui.test.tsx | 220 ++++++++++++++ tests/unit/coding-projects-facade.test.ts | 13 +- tests/unit/coding-workspace-store.test.ts | 35 +++ 21 files changed, 2040 insertions(+), 137 deletions(-) create mode 100644 .project-docs/30-worklog/tasks/20260824-pi-feature-ui-9d4e7a31.md create mode 100644 src/lib/coding-model-options.ts create mode 100644 src/pages/Chat/CodingAttachmentPreview.tsx create mode 100644 src/pages/Chat/CodingConversationHeader.tsx create mode 100644 src/pages/Chat/CodingInteractionPanel.tsx create mode 100644 src/pages/Chat/CodingWorkspaceInspector.tsx create mode 100644 tests/unit/coding-feature-ui.test.tsx diff --git a/.project-docs/30-worklog/tasks/20260824-pi-feature-ui-9d4e7a31.md b/.project-docs/30-worklog/tasks/20260824-pi-feature-ui-9d4e7a31.md new file mode 100644 index 0000000..00fd11a --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260824-pi-feature-ui-9d4e7a31.md @@ -0,0 +1,126 @@ +# Task: Implement PI-130 feature-complete Coding UI + +## Identity + +- Task ID: 20260824-pi-feature-ui-9d4e7a31 +- Mode: Feature +- Branch: codex/20260824-pi-feature-ui-9d4e7a31-pi-feature-ui +- Worktree: D:\Datas\OthersProjects\makelore-pi-feature-ui-9d4e7a31 +- Base commit: 2613d6530b2b19d43f85398af47fccf6cc483116 +- Owner: codex-root +- Status: Ready for Integration + +## Scope + +- Implement PI-130's feature-complete Coding UI on top of the PI-080/090/105/120 Host API and Conversation runtime contracts. +- Add conversation-scoped model and thinking controls, prompt/steer/follow-up queue controls, abort/compact/recover actions, and queue/run/context status. +- Render pending interactions, retry/compaction state, nested subagent graphs, tool details, changes, files, browser attachments, skills, commands, and diagnostics without reviving removed OpenCode product surfaces. +- Add conversation title/archive/unread/fork management. The fork action must be labelled `从这里创建新对话分支` and must not imply file rollback. +- Add focused Renderer/store tests and Electron E2E coverage for retained and explicitly removed UI behavior. +- Keep PI-140 source/dependency removal and PI-150 macOS verification outside this task. + +## Intent And Constraints + +- The planner task `codex://threads/01a0202d-abd3-7851-b7e7-6ca8ccdbb702` remains the authority for PI ordering, ticket acceptance, and final state transitions; this worktree is the PI-130 executor. +- Renderer backend access must remain behind `src/lib/host-api.ts` or `src/lib/api-client.ts`; Electron Main continues to own runtime, credentials, providers, synchronization, and system integration. +- Conversation model/thinking state is independent from partner defaults and isolated across simultaneously streaming Conversations. +- Queue release follows `agent_settled`, not `agent_end`; retry/compaction and interaction states must expose waiting, cancellation, stale, and failure outcomes without leaking raw compaction details. +- Do not add share/unshare, todo, global runtime, revert/unrevert, direct IPC, direct local-runtime HTTP, dark theme, compatibility wrappers, or incidental Agent/OpenCode template refactors. +- Real Provider validation remains explicitly waived with accepted concurrency, credential-isolation, and protocol-compatibility risk; `realTurnVerified` stays false and must never be reported as Pass. +- macOS x64/arm64 verification remains deferred to mandatory PI-150 and must never be reported as Pass in PI-130. +- Preserve unrelated user work and do not write to the main or planner worktrees. + +## Project Context Loaded + +Task context: +- Task ID: `20260824-pi-feature-ui-9d4e7a31` +- Mode: `feature` +- Branch: `codex/20260824-pi-feature-ui-9d4e7a31-pi-feature-ui` +- Worktree: `D:\Datas\OthersProjects\makelore-pi-feature-ui-9d4e7a31` +- Base commit: `2613d6530b2b19d43f85398af47fccf6cc483116` +- Other active local tasks: provider/model parity, Codex compaction review, generic client error tracing, Canvas E2E design, account lifecycle exploration, museum/media reviews, main model-switch integration, design delta/freeze audits, and this task. +- Overlap or semantic-conflict assessment: the only adjacent peer is provider/model parity and it declares no claimed files or concrete scope. The main integration task owns the main worktree only. Other peers are unrelated or read-only. Unknown-scope peers remain a coordination risk, but no declared semantic conflict changes this plan; all writes remain isolated in this task worktree. + +Read: +- `.project-docs/05-agent-entry/read-before-planning.md` +- `.project-docs/05-agent-entry/planning-gate.md` +- this active task record +- `.project-docs/05-agent-entry/memory-index.md` +- `.project-docs/30-worklog/current-state.md` +- `.project-docs/00-brief/project-positioning.md` +- `.project-docs/00-brief/success-criteria.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` +- `.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md` +- planner-owned PI runtime cutover spec, tickets, and coordination task record. + +Relevant understanding: +- Project goal: ship Makelore Code as a local, project-scoped, vendor-neutral Conversation product backed by Electron Main-owned runtime and Host APIs. +- Current integrated focus: PI-120 completed the first-chat timeline/composer shell; PI-130 is the only planner-approved Ready Frontier. +- Active task scope: complete the Coding UI capability surface and its focused verification without doing PI-140 source removal or PI-150 platform closure. +- Active constraints: Main owns runtime/providers/secrets; Renderer uses approved facades; no legacy share/todo/global-runtime/revert product surface; no direct IPC/HTTP; single light visual system. +- Decisions affecting this task: Conversation-scoped model/thinking, queue release on `agent_settled`, stable interaction and `subagent.v1` contracts, tool results nested in timeline entries, compacted summaries without raw-detail leakage, and branch wording without rollback semantics. +- Evidence, reflections, or commitments affecting this task: Real Provider verification is explicitly waived with accepted concurrency/credential/protocol risk; macOS verification is deferred and neither may be represented as Pass. +- Files or modules likely involved: `src/pages/Chat/Coding*`, `src/stores/coding-*`, `src/lib/coding-*`, shared Coding contracts, focused unit tests, and `tests/e2e/pi-coding-first-chat.spec.ts`. +- Unknowns, stale docs, or conflicts: several active peer records still have undefined scope; project positioning is a placeholder; no conflict was found between integrated architecture, the planner ticket, and the user request. + +Gate result: +- Passed. + +## Plan + +1. Extend vendor-neutral Renderer facades and stores for conversation metadata, runtime controls, interactions, diagnostics, model/thinking, compact/recover, and fork. +2. Add focused UI modules for conversation controls, queue modes, interaction responses, timeline retry/compaction/tool/subagent details, and conversation management. +3. Add the project tools inspector for changes, file preview/search, browser attachments, skills, and commands while preserving the Main-owned boundaries. +4. Add focused unit/store/UI tests and Electron E2E assertions for both retained capabilities and removed legacy entry points. +5. Run typecheck, relevant tests, lint, production Vite build, and scoped Electron E2E; record explicit Real Provider/macOS verification exceptions. +6. Complete the Task Documentation Gate, commit the executor branch, request planner review, and close PI-130 only after planner acceptance. + +## Outcome + +- Added vendor-neutral Renderer facades for abort, Conversation model/thinking, compact, recover, fork, interactions, diagnostics, and Conversation metadata mutation. +- Added Conversation-scoped model/thinking controls, run/retry/context status, compact/recover/abort actions, title/archive/unread/fork management, and active/hidden Conversation indicators. +- Added prompt/steer/follow-up composer modes with queue position and wait state. Direct prompts remain idle-only; steer/follow-up remain running-only; pending interactions and uncertain requests block conflicting sends. +- Added select/confirm/input/editor interaction cards with cancellation, stale-response feedback, and Conversation-scoped refresh. +- Extended the timeline with explicit thinking state, tool-detail renderers, inline tool results, browser attachment previews, safe compaction retry/settled language, and `subagent.v1` single/parallel/chain graphs with error/abort/skipped states. +- Added a Coding tools sheet for Conversation changes, project file find/content preview, browser attachments, skills, commands, and runtime diagnostics. Commands can be inserted into the selected Conversation draft. +- Replaced rollback-like branch language with `从这里创建新对话分支`; no file rollback behavior or implication was added. +- Kept share/unshare, todo, global runtime, revert/unrevert, direct IPC, direct runtime HTTP, and legacy OpenCode imports out of the new Coding UI. +- Windowed streaming thinking DOM output to the latest 16 KB while streaming, restoring the complete text when settled; this keeps the existing REN-008 100 KB pressure budget passing without changing the protocol or test threshold. +- Updated `README.md` to describe the now-shipped Coding UI state and retained the explicit unverified shared Provider/runtime concurrency boundary. + +## Verification + +- `pnpm run typecheck` — Pass. +- `pnpm run lint:check` — Pass with 0 errors and 6 pre-existing warnings outside PI-130-owned files. +- Focused PI-130 Vitest coverage — Pass, including facade routes, queue modes/positions, interactions, Conversation controls, metadata isolation, nested subagents, compaction retry, inline tool output, fork wording, and removed UI entries. +- `pnpm test` — Pass: 219 files; 2328 tests passed; 2 skipped. +- `pnpm run build:vite` — Pass as part of the scoped Electron E2E command; only existing Vite chunk/dynamic-import warnings were reported. +- `pnpm run test:e2e -- tests/e2e/pi-coding-first-chat.spec.ts` — Pass: 2/2, covering first-Conversation editability, two Conversation isolation, queue, interaction, model, abort, subagent, files/changes/commands tools, and removed share/revert/todo/global-runtime entries. +- `git diff --check` — Pass. +- New Coding UI boundary scan for `/api/opencode`, direct IPC/loopback access, share/unshare, todo, global runtime, and rollback UI — Pass. +- Real Provider validation — `Explicitly Waived / Accepted Risk`; concurrency, credential isolation, and protocol compatibility remain accepted risks. `realTurnVerified=false`; not Pass. +- macOS x64/arm64 validation — Deferred to mandatory PI-150; not Pass. + +## Follow-ups + +- PI-140 remains responsible for deleting the old OpenCode Renderer/Main/Preload sources, routes, dependencies, and packaged legacy runtime surface after PI-130 integration. +- PI-150 remains responsible for mandatory macOS x64/arm64 verification and final release closure. +- Planner review must decide the PI-130 Done transition and the next Ready Frontier; this executor does not change planner-owned ticket state directly. + +## Promotion Candidates + +- Target canonical document: planner-owned `20260822-pi-runtime-spec-b6e2c9a4__pi-runtime-cutover-tickets.md` and planner coordination task. + Proposal: after planner review of the executor commit, mark PI-130 Done and evaluate PI-140 as the next Ready Frontier. + Evidence: the executor commit from this task plus the verification results above. + Future impact: enables old OpenCode product-source removal without leaving retained capabilities absent from the new Coding UI. + Semantic conflicts: Real Provider remains explicitly waived and macOS remains deferred; neither may be promoted to Pass. No conflict with the hard-cut architecture or PI-140 ownership was found. + Human confirmation required: yes, planner acceptance is required for the PI state transition. diff --git a/README.md b/README.md index a5200f4..cdf3b4a 100644 --- a/README.md +++ b/README.md @@ -121,17 +121,18 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode ### 项目伙伴与会话 - 新项目默认没有伙伴或 Agent;用户在项目内手动创建伙伴时必须填写名称、预设头像或本地头像、职责和精确的 `provider/model`。本地头像会自动裁剪为 256×256,并优先压缩为 WebP 后随项目配置保存;提示词与 Skill 属于后置高级设置。 -- 项目配置页的新增与已有伙伴维护统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。模型资源抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。伙伴模型是新 Conversation 的默认值;当前核心聊天页只提供发送、图片附件与恢复,Conversation 级模型和 thinking 控件尚未出现在该页面。 +- 项目配置页的新增与已有伙伴维护统一使用居中弹窗;编辑保存先更新当前页面草稿,点击底部“保存项目配置”后统一持久化。模型资源抽屉只展示已配置模型、提供方和文本/多模态能力,不提供项目级选择。伙伴模型只作为新 Conversation 的默认值;核心聊天页可为当前 Conversation 独立切换模型和 thinking,切换不会改写伙伴默认值或其他 Conversation。 - 读取旧项目时会把仍缺少模型的伙伴从兼容保留的 `defaultModel` 自动迁移到伙伴自身配置,之后运行时只认伙伴模型。 - 技能资源入口使用扳手图标;点击已安装 Skill 后先展示其目录结构,再展示主文件 `SKILL.md` 原文,并支持返回技能列表。 - 一个伙伴可以拥有多条互相独立的 Pi Session;伙伴和 Conversation 元数据分别由项目配置与 `.niancode/conversations.json` 保存,稳定 Agent/Conversation id 保持本地历史连续。 - 核心聊天页左侧按伙伴展示本地 Conversation,并提供新建入口;首次选择没有 Conversation 的伙伴时立即创建本地元数据,同时异步准备对应运行时。即使准备被阻塞或超时,输入框仍可编辑,草稿也不会丢失。 - Conversation 历史按需从 Main-owned Snapshot 读取。公开 SSE 只交付 Snapshot 与按 Conversation、worker generation 分组的 `patch-batch`;Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation,隐藏 Conversation 的流式更新不会提交选中时间线。 -- 核心时间线渲染消息、Markdown、thinking、工具、压缩、轮次边界和通知;默认保留最近 120 个节点的渲染窗口,可按 100 个节点加载更早内容。工具累计输出覆盖同一块而不重复追加。 +- 核心时间线渲染消息、Markdown、thinking、工具、压缩、轮次边界、通知和 `subagent.v1` 单个/并行/串行子任务;默认保留最近 120 个节点的渲染窗口,可按 100 个节点加载更早内容。工具结果和浏览器附件保留在对应工具卡片内,累计输出覆盖同一块而不形成独立气泡;压缩只展示产品摘要、重试和结算状态。 - Composer 支持文字、粘贴或选择 PNG/JPEG/WebP/GIF 图片,每条消息最多 16 张、最多并行上传 4 张。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;Main 在落盘前核对 MIME 与最小图片签名,状态与事件只保存 attachment id,时间线按需读取二进制并创建临时 object URL,不保存重复 base64。 - Makelore 在应用侧按 Session 独立提交、跟踪和隔离运行状态,不使用“当前对话正在回复”的全局界面锁;同一 Session 的后续消息仍按顺序排队。共享 runtime/provider 是否真正并发执行不同 Session 尚未经过自动化真实运行 smoke 验证,运行时仍可能自行串行、限流或拒绝请求。 - 首次发送会立即生成稳定的乐观用户消息;HTTP 202 只表示本地 Agent 已接收。后续失败不会删除已接受消息,不确定交付不会自动重发;准备失败可在目标 Conversation 上手动恢复。 -- 归档、分支、标题、未读、模型/thinking、队列、交互、子任务、changes/files/browser/skills/commands 等完整 Conversation 操作当前不属于核心聊天页。 +- 运行中的 Conversation 可把新消息作为 steer 引导当前回答或 follow-up 排到下一轮,并显示队列位置;队列只在 `agent_settled` 后释放,用户可中止当前运行。select/confirm/input/editor 交互在输入区上方回答,并明确展示取消或失效结果。 +- 核心聊天页支持标题、归档、未读、恢复,以及“从这里创建新对话分支”;分支只创建新的 Conversation 历史,不表示文件回滚。右侧编程工具集中展示当前 Conversation 的 changes、项目文件预览、浏览器附件、技能、命令与脱敏运行诊断。分享、待办、全局运行时和 revert/unrevert 不属于该产品界面。 ## 兼容标识 diff --git a/src/lib/coding-conversations.ts b/src/lib/coding-conversations.ts index 50cbea2..108d685 100644 --- a/src/lib/coding-conversations.ts +++ b/src/lib/coding-conversations.ts @@ -1,8 +1,15 @@ import type { + CodingRuntimeDiagnostics, + ConversationInteraction, + ConversationInteractionResponse, + ConversationModelState, ConversationSnapshot, + ConversationThinkingLevel, + ProductModelRef, PromptAcceptance, PromptMode, } from '@/types/coding-conversation'; +import type { CodingConversationMetadata } from '@/types/coding-project'; import { createHostEventSource, ensureHostApiToken, @@ -53,6 +60,89 @@ export async function recoverCodingConversation( ); } +export async function abortCodingConversation(conversationId: string): Promise { + await hostApiFetch( + `/api/coding/conversations/${encodeURIComponent(conversationId)}/abort`, + { method: 'POST', body: '{}' }, + ); +} + +export async function setCodingConversationModel( + conversationId: string, + model: ProductModelRef, +): Promise { + const response = await hostApiFetch<{ model: ConversationModelState }>( + `/api/coding/conversations/${encodeURIComponent(conversationId)}/model`, + { method: 'POST', body: JSON.stringify({ model }) }, + ); + return response.model; +} + +export async function setCodingConversationThinking( + conversationId: string, + thinkingLevel: ConversationThinkingLevel, +): Promise { + const response = await hostApiFetch<{ model: ConversationModelState }>( + `/api/coding/conversations/${encodeURIComponent(conversationId)}/thinking`, + { method: 'POST', body: JSON.stringify({ thinkingLevel }) }, + ); + return response.model; +} + +export async function compactCodingConversation(conversationId: string): Promise { + await hostApiFetch( + `/api/coding/conversations/${encodeURIComponent(conversationId)}/compact`, + { method: 'POST', body: '{}' }, + ); +} + +export async function forkCodingConversation( + conversationId: string, + sourceEntryId?: string, +): Promise { + const response = await hostApiFetch<{ conversation: CodingConversationMetadata }>( + `/api/coding/conversations/${encodeURIComponent(conversationId)}/fork`, + { + method: 'POST', + body: JSON.stringify(sourceEntryId ? { sourceEntryId } : {}), + }, + ); + return response.conversation; +} + +export async function listCodingConversationInteractions( + conversationId?: string, +): Promise { + const params = new URLSearchParams(); + if (conversationId) params.set('conversationId', conversationId); + const query = params.toString(); + const response = await hostApiFetch<{ interactions: ConversationInteraction[] }>( + `/api/coding/interactions${query ? `?${query}` : ''}`, + ); + return response.interactions; +} + +export async function respondCodingConversationInteraction( + conversationId: string, + response: ConversationInteractionResponse, +): Promise { + const { interactionId, ...answer } = response; + await hostApiFetch( + `/api/coding/interactions/${encodeURIComponent(interactionId)}/respond`, + { + method: 'POST', + body: JSON.stringify({ conversationId, ...answer }), + }, + ); +} + +export async function getCodingRuntimeDiagnostics(): Promise { + const response = await hostApiFetch<{ runtime: CodingRuntimeDiagnostics }>( + '/api/coding/runtime/diagnostics', + ); + return response.runtime; +} + export async function openCodingConversationEvents( conversationId?: string, ): Promise { diff --git a/src/lib/coding-model-options.ts b/src/lib/coding-model-options.ts new file mode 100644 index 0000000..ab64996 --- /dev/null +++ b/src/lib/coding-model-options.ts @@ -0,0 +1,58 @@ +import type { ProviderAccount, ProviderVendorInfo } from '@/lib/providers'; +import type { ProductModelRef } from '@/types/coding-conversation'; + +export interface CodingModelOption { + key: string; + accountId: string; + modelId: string; + label: string; +} + +export function codingModelKey(model: Pick): string { + return JSON.stringify([model.accountId, model.modelId]); +} + +export function parseCodingModelKey(key: string): Pick | null { + try { + const value = JSON.parse(key) as unknown; + if (!Array.isArray(value) || value.length !== 2) return null; + const [accountId, modelId] = value; + if (typeof accountId !== 'string' || !accountId.trim() + || typeof modelId !== 'string' || !modelId.trim()) return null; + return { accountId: accountId.trim(), modelId: modelId.trim() }; + } catch { + return null; + } +} + +export function buildCodingModelOptions( + accounts: ProviderAccount[], + vendors: ProviderVendorInfo[], +): CodingModelOption[] { + const vendorNames = new Map(vendors.map((vendor) => [vendor.id, vendor.name])); + const seen = new Set(); + const options: CodingModelOption[] = []; + for (const account of accounts) { + if (!account.enabled) continue; + const modelIds = [ + account.model, + ...(account.fallbackModels ?? []), + ...(account.metadata?.customModels ?? []), + ]; + for (const value of modelIds) { + const modelId = value?.trim(); + if (!modelId) continue; + const key = codingModelKey({ accountId: account.id, modelId }); + if (seen.has(key)) continue; + seen.add(key); + const vendor = vendorNames.get(account.vendorId); + options.push({ + key, + accountId: account.id, + modelId, + label: `${account.label}${vendor && vendor !== account.label ? ` · ${vendor}` : ''} / ${modelId}`, + }); + } + } + return options; +} diff --git a/src/lib/coding-projects.ts b/src/lib/coding-projects.ts index c0dbb29..de0ccdf 100644 --- a/src/lib/coding-projects.ts +++ b/src/lib/coding-projects.ts @@ -46,3 +46,21 @@ export async function createCodingProjectConversation(input: { ); return response.conversation; } + +export async function patchCodingProjectConversation( + conversationId: string, + patch: { title?: string; archived?: boolean; unread?: boolean }, +): Promise { + const response = await hostApiFetch<{ conversation: CodingConversationMetadata }>( + `/api/coding/conversations/${encodeURIComponent(conversationId)}`, + { method: 'PATCH', body: JSON.stringify(patch) }, + ); + return response.conversation; +} + +export async function deleteCodingProjectConversation(conversationId: string): Promise { + await hostApiFetch( + `/api/coding/conversations/${encodeURIComponent(conversationId)}`, + { method: 'DELETE' }, + ); +} diff --git a/src/pages/Chat/CodingAttachmentPreview.tsx b/src/pages/Chat/CodingAttachmentPreview.tsx new file mode 100644 index 0000000..1463ab3 --- /dev/null +++ b/src/pages/Chat/CodingAttachmentPreview.tsx @@ -0,0 +1,66 @@ +import { memo, useEffect, useState } from 'react'; +import { FileImage } from 'lucide-react'; +import { loadCodingAttachmentPreview } from '@/lib/coding-attachments'; + +export const CodingAttachmentPreview = memo(function CodingAttachmentPreview({ + attachmentId, + mime, + caption = '对话图片附件', +}: { + attachmentId: string; + mime: string; + caption?: string; +}) { + const [state, setState] = useState<{ + attachmentId: string; + previewUrl: string | null; + failed: boolean; + }>({ attachmentId, previewUrl: null, failed: false }); + + useEffect(() => { + let active = true; + let objectUrl: string | null = null; + void loadCodingAttachmentPreview(attachmentId) + .then((preview) => { + if (!active) return; + const bytes = Uint8Array.from(preview.bytes); + objectUrl = URL.createObjectURL(new Blob([bytes], { type: preview.mime })); + setState({ attachmentId, previewUrl: objectUrl, failed: false }); + }) + .catch(() => { + if (active) setState({ attachmentId, previewUrl: null, failed: true }); + }); + return () => { + active = false; + if (objectUrl) URL.revokeObjectURL(objectUrl); + }; + }, [attachmentId]); + + const previewUrl = state.attachmentId === attachmentId ? state.previewUrl : null; + const failed = state.attachmentId === attachmentId && state.failed; + + return ( +
+ {previewUrl + ? ( + {caption} + ) + : ( +
+
+ )} +
+ {mime} · {attachmentId} +
+
+ ); +}); diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index f4b7f52..0f7a8e6 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -5,7 +5,6 @@ import { LoaderCircle, MessageSquarePlus, RefreshCw, - Settings2, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -16,6 +15,7 @@ import { uploadCodingAttachment, type CodingAttachmentRef, } from '@/lib/coding-attachments'; +import { forkCodingConversation } from '@/lib/coding-conversations'; import { cn } from '@/lib/utils'; import { codingConversationStore, @@ -28,12 +28,16 @@ import { import { codingWorkspaceStore, useCodingWorkspaceStore } from '@/stores/coding-workspace'; import type { CodingDraftAttachment, + PromptMode, } from '@/types/coding-conversation'; import type { CodingConversationMetadata, } from '@/types/coding-project'; import { CodingComposer } from './CodingComposer'; +import { CodingConversationHeader } from './CodingConversationHeader'; import { CodingConversationTimeline } from './CodingConversationTimeline'; +import { CodingInteractionPanel } from './CodingInteractionPanel'; +import { CodingWorkspaceInspector } from './CodingWorkspaceInspector'; import { createLocalConversationSnapshot } from './coding-chat-snapshot'; export interface CodingChatPanelProps { @@ -72,6 +76,16 @@ const SUBMITTING_REQUEST_STATUSES = new Set(['pending']); const ACCEPTED_REQUEST_STATUSES = new Set(['accepted']); const UNCERTAIN_REQUEST_STATUSES = new Set(['uncertain']); +function acceptedPromptCount( + state: CodingConversationStoreState, + conversationId: string | null, +): number { + if (!conversationId) return 0; + return Object.values(selectCodingConversationRequests(conversationId)(state)).filter((request) => ( + request.status === 'accepted' && request.mode === 'prompt' + )).length; +} + export function CodingChatPanel({ navigationDraft, onOpenProjectSettings, @@ -87,6 +101,8 @@ export function CodingChatPanel({ const selectAgent = useCodingWorkspaceStore((state) => state.selectAgent); const ensureConversation = useCodingWorkspaceStore((state) => state.ensureConversation); const createConversation = useCodingWorkspaceStore((state) => state.createConversation); + const patchConversation = useCodingWorkspaceStore((state) => state.patchConversation); + const upsertConversation = useCodingWorkspaceStore((state) => state.upsertConversation); const selectedConversationId = useCodingConversationStore((state) => state.selectedConversationId); const connectionState = useCodingConversationStore((state) => state.connectionState); @@ -98,9 +114,14 @@ export function CodingChatPanel({ const setConversationDraft = useCodingConversationStore((state) => state.setDraft); const submitPrompt = useCodingConversationStore((state) => state.submitPrompt); const recoverConversation = useCodingConversationStore((state) => state.recoverConversation); + const loadConversationSnapshot = useCodingConversationStore((state) => state.loadSnapshot); + const markConversationUnread = useCodingConversationStore((state) => state.markUnread); + const conversationSummaries = useCodingConversationStore((state) => state.summariesByConversationId); const [provisionalDrafts, setProvisionalDrafts] = useState>({}); const [submissionErrors, setSubmissionErrors] = useState>({}); + const [modesByDraftKey, setModesByDraftKey] = useState>({}); + const [inspectorOpen, setInspectorOpen] = useState(false); const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState< Record >({}); @@ -132,6 +153,7 @@ export function CodingChatPanel({ ? `new:${activeProject.id}:${selectedAgent.id}` : null; const draftKey = targetConversationId ?? provisionalDraftKey; + const promptMode = draftKey ? modesByDraftKey[draftKey] ?? 'prompt' : 'prompt'; const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : ''; const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null; const localAttachments = useMemo(() => ( @@ -172,6 +194,10 @@ export function CodingChatPanel({ targetConversationId, ACCEPTED_REQUEST_STATUSES, )); + const acceptedPromptRequestCount = useCodingConversationStore((state) => acceptedPromptCount( + state, + targetConversationId, + )); const uncertainRequestCount = useCodingConversationStore((state) => requestCount( state, targetConversationId, @@ -210,6 +236,8 @@ export function CodingChatPanel({ if (selectedConversation?.agentId === selectedAgent.id && !selectedConversation.archivedAt) return; const next = newestConversation(conversations, selectedAgent.id); if (next) { + markConversationUnread(next.id, false); + if (next.unread) void patchConversation(next.id, { unread: false }).catch(() => undefined); void selectConversation(next.id).catch(() => undefined); return; } @@ -231,6 +259,8 @@ export function CodingChatPanel({ clearConversationSelection, conversations, ensureConversation, + markConversationUnread, + patchConversation, primeConversation, selectConversation, selectedAgent, @@ -277,8 +307,12 @@ export function CodingChatPanel({ const handleSelectConversation = useCallback((conversation: CodingConversationMetadata) => { if (!activeProject) return; primeConversation(createLocalConversationSnapshot(activeProject.id, conversation)); + markConversationUnread(conversation.id, false); + if (conversation.unread) { + void patchConversation(conversation.id, { unread: false }).catch(() => undefined); + } void selectConversation(conversation.id).catch(() => undefined); - }, [activeProject, primeConversation, selectConversation]); + }, [activeProject, markConversationUnread, patchConversation, primeConversation, selectConversation]); const handleCreateConversation = useCallback(async () => { if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return; @@ -300,6 +334,14 @@ export function CodingChatPanel({ selectedAgent, ]); + const handleForkConversation = useCallback(async (sourceEntryId?: string) => { + if (!activeProject || !targetConversationId) return; + const forked = await forkCodingConversation(targetConversationId, sourceEntryId); + upsertConversation(forked); + primeConversation(createLocalConversationSnapshot(activeProject.id, forked)); + await selectConversation(forked.id); + }, [activeProject, primeConversation, selectConversation, targetConversationId, upsertConversation]); + const handleAddFiles = useCallback((files: File[]) => { if (!draftKey || (targetConversationId && submissionFlightsRef.current.has(targetConversationId))) return; @@ -411,7 +453,7 @@ export function CodingChatPanel({ }); void submitPrompt({ conversationId, - mode: 'prompt', + mode: promptMode, prepareAttachments: attachments.length > 0 ? () => prepareAttachments(attachments) : undefined, @@ -437,6 +479,7 @@ export function CodingChatPanel({ draftKey, localAttachments, prepareAttachments, + promptMode, targetConversationId, submitPrompt, ]); @@ -451,17 +494,23 @@ export function CodingChatPanel({ || runStatus === 'preparing'; const recovering = entryLoadState === 'recovering'; const running = ['queued', 'running', 'retrying', 'compacting', 'aborting'].includes(runStatus); + const modeMatchesRunState = running ? promptMode !== 'prompt' : promptMode === 'prompt'; + const pendingInteractionCount = snapshot?.pendingInteractions.filter((interaction) => ( + interaction.status === 'pending' + )).length ?? 0; const draft = selectedDraft?.text ?? provisionalDraft; const editable = Boolean(activeProject && selectedAgent); const canSend = Boolean( editable && targetConversationId && (draft.trim() || localAttachments.length > 0) - && !running + && modeMatchesRunState && submittingRequestCount === 0 && !preparationError - && blockingRequestCount === 0 - && uncertainRequestCount === 0, + && (running || acceptedPromptRequestCount === 0) + && blockingRequestCount - acceptedRequestCount === 0 + && uncertainRequestCount === 0 + && pendingInteractionCount === 0, ); const autoCreating = Boolean(selectedAgent && creatingAgentIds[selectedAgent.id]); @@ -531,21 +580,29 @@ export function CodingChatPanel({
- {agentConversations.map((conversation) => ( - - ))} + {agentConversations.map((conversation) => { + const unread = conversationSummaries[conversation.id]?.unread ?? conversation.unread; + const summary = conversationSummaries[conversation.id]; + return ( + + ); + })} {autoCreating && (
+ + {conversation && ( +
+ + +
+ 上下文 {context?.usedTokens ?? 0}/{context?.contextWindow ?? 0} + + {context?.recalculating && } +
+ + {recoverable && ( + + )} + + + +
+ )} + {actionError &&

{actionError}

} + + + + + 修改对话标题 + 标题只影响当前 Conversation,不会改动伙伴名称。 + + setTitleDraft(event.target.value)} /> + + + + + + + + ); +} diff --git a/src/pages/Chat/CodingConversationTimeline.tsx b/src/pages/Chat/CodingConversationTimeline.tsx index 3a341e6..f83a5f2 100644 --- a/src/pages/Chat/CodingConversationTimeline.tsx +++ b/src/pages/Chat/CodingConversationTimeline.tsx @@ -1,7 +1,6 @@ import { memo, useCallback, - useEffect, useLayoutEffect, useRef, useState, @@ -13,13 +12,16 @@ import { Brain, ChevronDown, CircleAlert, - FileImage, + GitFork, + Globe2, Hammer, Layers3, + ListChecks, + ShieldCheck, UserRound, + Workflow, } from 'lucide-react'; import { Button } from '@/components/ui/button'; -import { loadCodingAttachmentPreview } from '@/lib/coding-attachments'; import { cn } from '@/lib/utils'; import { selectCodingConversationSnapshot, @@ -33,13 +35,33 @@ import type { ConversationMessageNode, ConversationNode, ConversationNoticeNode, + ConversationSubagentNode, ConversationToolNode, + KnownToolDetails, + SubagentDetailsV1, } from '../../../shared/coding-conversation-contracts'; +import { CodingAttachmentPreview } from './CodingAttachmentPreview'; const EMPTY_NODES: ConversationNode[] = []; const INITIAL_WINDOW = 120; const WINDOW_STEP = 100; const LONG_OUTPUT_CHARS = 4_000; +const STREAMING_THINKING_WINDOW_CHARS = 16_000; + +const NODE_STATUS_LABELS: Record = { + declared: '已声明', + waiting: '等待中', + running: '执行中', + complete: '已完成', + error: '失败', + aborted: '已中止', + queued: '排队中', + skipped: '已跳过', +}; + +function statusLabel(status: string): string { + return NODE_STATUS_LABELS[status] ?? status; +} const MarkdownText = memo(function MarkdownText({ text }: { text: string }) { return ( @@ -75,60 +97,6 @@ const MarkdownText = memo(function MarkdownText({ text }: { text: string }) { ); }); -const AttachmentImage = memo(function AttachmentImage({ - attachmentId, - mime, -}: { - attachmentId: string; - mime: string; -}) { - const [previewUrl, setPreviewUrl] = useState(null); - const [failed, setFailed] = useState(false); - useEffect(() => { - let active = true; - let objectUrl: string | null = null; - void loadCodingAttachmentPreview(attachmentId) - .then((preview) => { - if (!active) return; - const bytes = Uint8Array.from(preview.bytes); - objectUrl = URL.createObjectURL(new Blob([bytes], { type: preview.mime })); - setPreviewUrl(objectUrl); - }) - .catch(() => { - if (active) setFailed(true); - }); - return () => { - active = false; - if (objectUrl) URL.revokeObjectURL(objectUrl); - }; - }, [attachmentId]); - - return ( -
- {previewUrl - ? ( - 对话图片附件 - ) - : ( -
-
- )} -
- {mime} · {attachmentId} -
-
- ); -}); - const ContentBlock = memo(function ContentBlock({ block, markdown, @@ -140,9 +108,14 @@ const ContentBlock = memo(function ContentBlock({ block.kind === 'thinking' && block.status === 'streaming', ); if (block.kind === 'image') { - return ; + return ; } if (block.kind === 'thinking') { + const thinkingText = block.status === 'streaming' + && block.text.length > STREAMING_THINKING_WINDOW_CHARS + ? block.text.slice(-STREAMING_THINKING_WINDOW_CHARS) + : block.text; + const windowed = thinkingText.length !== block.text.length; return (
); } @@ -166,13 +141,19 @@ const ContentBlock = memo(function ContentBlock({ return ; }); -const MessageNode = memo(function MessageNode({ node }: { node: ConversationMessageNode }) { +const MessageNode = memo(function MessageNode({ + node, + onFork, +}: { + node: ConversationMessageNode; + onFork?(sourceEntryId: string): void; +}) { const user = node.role === 'user'; return (
{!user && (
@@ -201,6 +182,25 @@ const MessageNode = memo(function MessageNode({ node }: { node: ConversationMess 本次提交未被接受,内容已保留在输入框。

)} + {node.status === 'aborted' && ( +

+ 本轮已中止。 +

+ )} + {node.sourceEntryId && onFork && node.status !== 'optimistic' && ( + + )}
{user && (
@@ -211,12 +211,107 @@ const MessageNode = memo(function MessageNode({ node }: { node: ConversationMess ); }); +function subagentModeLabel(mode: SubagentDetailsV1['mode']): string { + if (mode === 'parallel') return '并行子任务'; + if (mode === 'chain') return '串行子任务'; + return '单个子任务'; +} + +const SubagentGraph = memo(function SubagentGraph({ details }: { details: SubagentDetailsV1 }) { + return ( +
+
+
+
+ {details.tasks.map((task, index) => ( +
+ {details.mode === 'chain' && index > 0 && ( +
+ ))} +
+
+ ); +}); + +const ToolDetails = memo(function ToolDetails({ details }: { details: KnownToolDetails }) { + if (details.schema === 'changed-file.v1') { + return ( +
+

本轮变更文件

+ {details.paths.map((path) =>

{path}

)} +
+ ); + } + if (details.schema === 'task-state.v1') { + return ( +
+

+ {details.tasks.map((task) => ( +

{task.title}{statusLabel(task.status)}

+ ))} +
+ ); + } + if (details.schema === 'write-lease.v1') { + return

; + } + if (details.schema === 'agent-browser.v1') { + return ( +
+

+ {details.attachmentId && details.mime && ( + + )} +
+ ); + } + if (details.schema === 'game-assets.v1') { + return ( +
+

素材确认 · {details.status === 'pending' ? '等待处理' : '已完成'}

+

待确认 {details.pendingAssetIds.length} · 已采用 {details.approvedAssetIds.length} · 已丢弃 {details.discardedAssetIds.length}

+
+ ); + } + if (details.schema === 'runtime-context.v1') { + return ( +
+

技能

{details.skills.map((skill) =>

{skill.selected ? '已启用 · ' : ''}{skill.name}

)}
+

命令

{details.commands.map((command) =>

/{command.name}

)}
+
+ ); + } + return ; +}); + const ToolNode = memo(function ToolNode({ node }: { node: ConversationToolNode }) { const outputChars = node.output.reduce((total, block) => ( total + (block.kind === 'image' ? 0 : block.text.length) ), 0); - const open = node.status === 'running' || node.status === 'waiting'; - const [expanded, setExpanded] = useState(open); + const initiallyOpen = node.status === 'running' || node.status === 'waiting'; + const [expanded, setExpanded] = useState(initiallyOpen); return (
@@ -257,8 +353,13 @@ const CompactionNode = memo(function CompactionNodeView({ node }: { node: Conver >
); @@ -268,8 +369,8 @@ const BoundaryNode = memo(function BoundaryNodeView({ node }: { node: Conversati const label = node.boundary === 'turn-start' ? '开始新一轮' : node.boundary === 'turn-end' - ? '本轮完成' - : `正在重试${node.attempt ? ` · 第 ${node.attempt} 次` : ''}`; + ? '本轮已结算' + : `正在重试${node.attempt ? ` · 第 ${node.attempt} 次` : ''}${node.delayMs ? ` · ${Math.ceil(node.delayMs / 1000)} 秒后` : ''}`; return (
-
); }); -const TimelineNode = memo(function TimelineNode({ node }: { node: ConversationNode }) { - if (node.kind === 'message') return ; +const SubagentNode = memo(function SubagentNodeView({ node }: { node: ConversationSubagentNode }) { + return ( +
+ +
+ ); +}); + +const TimelineNode = memo(function TimelineNode({ + node, + onFork, +}: { + node: ConversationNode; + onFork?(sourceEntryId: string): void; +}) { + if (node.kind === 'message') return ; if (node.kind === 'tool') return ; if (node.kind === 'compaction') return ; if (node.kind === 'boundary') return ; - if (node.kind === 'notice') return ; - return ( -
- 子任务执行状态将在后续功能面板中展开。 -
- ); + if (node.kind === 'subagent') return ; + return ; }); export const CodingConversationTimeline = memo(function CodingConversationTimeline({ conversationId, + onFork, }: { conversationId: string; + onFork?(sourceEntryId: string): void; }) { const selectNodes = useCallback((state: CodingConversationStoreState) => ( selectCodingConversationSnapshot(conversationId)(state)?.nodes ?? EMPTY_NODES @@ -352,7 +468,9 @@ export const CodingConversationTimeline = memo(function CodingConversationTimeli 加载更早内容 )} - {nodes.slice(windowStart).map((node) => )} + {nodes.slice(windowStart).map((node) => ( + + ))} {nodes.length === 0 && (
diff --git a/src/pages/Chat/CodingInteractionPanel.tsx b/src/pages/Chat/CodingInteractionPanel.tsx new file mode 100644 index 0000000..dc068fe --- /dev/null +++ b/src/pages/Chat/CodingInteractionPanel.tsx @@ -0,0 +1,147 @@ +import { useState } from 'react'; +import { Check, CircleHelp, LoaderCircle, X } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Textarea } from '@/components/ui/textarea'; +import { respondCodingConversationInteraction } from '@/lib/coding-conversations'; +import type { + ConversationInteraction, + ConversationInteractionResponse, +} from '@/types/coding-conversation'; + +function settledLabel(status: ConversationInteraction['status']): string { + if (status === 'answered') return '已回答'; + if (status === 'cancelled') return '已取消'; + if (status === 'rejected') return '已失效'; + return '等待回答'; +} + +export function CodingInteractionPanel({ + conversationId, + interactions, + onSettled, +}: { + conversationId: string; + interactions: ConversationInteraction[]; + onSettled?(): Promise | void; +}) { + const [values, setValues] = useState>({}); + const [busyId, setBusyId] = useState(null); + const [errors, setErrors] = useState>({}); + + if (interactions.length === 0) return null; + + const respond = (response: ConversationInteractionResponse) => { + if (busyId) return; + setBusyId(response.interactionId); + setErrors((current) => { + const next = { ...current }; + delete next[response.interactionId]; + return next; + }); + void respondCodingConversationInteraction(conversationId, response) + .then(() => onSettled?.()) + .catch((error) => { + setErrors((current) => ({ + ...current, + [response.interactionId]: `${error instanceof Error ? error.message : String(error)}。请求可能已失效,请刷新对话。`, + })); + }) + .finally(() => setBusyId((current) => current === response.interactionId ? null : current)); + }; + + return ( +
+ {interactions.map((interaction) => { + const pending = interaction.status === 'pending'; + const busy = busyId === interaction.id; + const value = values[interaction.id] ?? ''; + return ( +
+
+
+ + {pending && interaction.kind === 'select' && ( +
+ {(interaction.options ?? []).map((option) => ( + + ))} +
+ )} + + {pending && interaction.kind === 'confirm' && ( +
+ + +
+ )} + + {pending && (interaction.kind === 'input' || interaction.kind === 'editor') && ( +
+ {interaction.kind === 'editor' + ? ( +