diff --git a/.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md b/.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md index 69e2df0..e09f148 100644 --- a/.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md +++ b/.project-docs/30-worklog/tasks/20260823-pi-chat-timeline-c8f31a62.md @@ -130,24 +130,60 @@ buffers during target recovery, trims Snapshot-covered items and replays only a strict continuous tail. - Fixed a supported concurrency defect exposed by the full suite: the Pi - Session Registry now reuses one Conversation store per project, serializing - simultaneous first bindings instead of racing writes to the same metadata - file. + Session Registry and Coding Project Service now receive the same project- + scoped Conversation store provider, so runtime bindings and metadata writes + serialize through one mutation queue instead of racing the same file. +- Closed the first planner review findings after candidate `612135f`: the + authenticated Host server now admits only the exact image-binary upload + exception; empty/invalid/oversize/not-found attachments have stable typed + errors and supported image signatures are checked before disk writes. +- Made active target ownership atomic across Agent switches. An old or archived + Conversation cannot supply the visible draft/timeline/prompt target, and a + slow automatic or explicit create completion only selects when its captured + project and Agent are still active. Submission flights and errors are scoped + per Conversation, so one pending/rejected target does not disable another. +- Bounded each message to 16 local images and four concurrent uploads. A real + Electron image fixture now traverses the Renderer facade, IPC channel and + authenticated Host server, reads the stored bytes back for the optimistic + timeline preview, observes one upload, and verifies the prompt contains only + attachment ids rather than base64. +- Extended target-only gap recovery: when the first recovery Snapshot remains + behind a buffered continuous tail, Renderer requests one fresh target + Snapshot; a repeated identical stale response becomes a retryable error + instead of remaining in an infinite recovering state. - Synchronized `README.md` with the current Pi core Chat, first-Conversation, batch and attachment behavior. PI-130 feature controls and PI-140 legacy removal remain outside this task. ## Verification -- `pnpm exec vitest run tests/unit/coding-workspace-store.test.ts tests/unit/coding-projects-facade.test.ts tests/unit/coding-attachments-facade.test.ts tests/unit/coding-attachments-routes.test.ts tests/unit/coding-chat-panel.test.tsx tests/unit/coding-conversation-timeline.test.tsx tests/unit/coding-conversations-store.test.tsx tests/unit/coding-core-routes.test.ts tests/unit/host-api.test.ts tests/unit/host-api-proxy.test.ts`: passed, 10 files / 68 tests before the final malformed-batch and Session Registry regression additions. -- `pnpm exec vitest run tests/unit/pi-session-registry.test.ts tests/unit/pi-conversation-runtime.test.ts tests/unit/coding-conversations-store.test.tsx tests/unit/coding-chat-panel.test.tsx`: passed, 4 files / 25 tests. -- `pnpm test`: passed, 217 files / 2303 passed / 2 skipped. The final run is green after fixing the same-project parallel Session Registry write race and stabilizing the AI Hardware async query at its actual loading boundary. +- First implementation candidate `612135f` received planner review result + **Standards Needs Fix / Spec Needs Fix**. PI-120 remained the unique Ready + Frontier; the findings above were treated as blocking rather than advancing + PI-130. +- Post-review focused suites passed: attachment Host routes, stale recovery, + shared ProjectService/Registry mutation queue, slow Agent switch, cross- + Conversation submission isolation, attachment count/concurrency and pressure + tests all pass. The consolidated focused run covered 3 files / 30 tests, and + `coding-chat-panel.test.tsx` covered 6/6 behavior tests. +- `REN-008` actual-path fixture passed with 100 runtime patch items, 20 public + patch batches, 21 SSE frames including Snapshot, 20 Renderer transactions, + 20 React commits, 131,148 wire bytes, and measured Main-to-React p95 + `35.995 ms` against the `<=50 ms` budget. It includes mixed message blocks, + 4 KiB tool output and more than 100 KiB of cumulative thinking output. +- `pnpm test`: passed, 218 files / 2316 passed / 2 skipped. - `pnpm run typecheck`: passed. -- `pnpm run lint:check`: passed with 0 errors and six unchanged warnings in `ExecutionGraphCard`, `Home`, and `Makelore`. -- `pnpm run build:vite`: passed; existing Vite dynamic-import and chunk-size warnings remain. -- `pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts`: passed, 1/1. -- `pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts --repeat-each=20 --workers=1`: passed, 20/20 fresh-userData runs. Every sample asserted Composer editability below 500 ms while runtime Snapshot was held, so the measured sample p95 is below the locked 500 ms budget; the same fixture verifies optimistic prompt visibility and HTTP 202 acceptance without a real Provider. -- `git diff --check`: passed; only repository line-ending conversion notices were printed. +- `pnpm run lint:check`: passed with 0 errors and six unchanged warnings in + `ExecutionGraphCard`, `Home`, and `Makelore`. +- `pnpm run build:vite`: passed; existing Vite dynamic-import and chunk-size + warnings remain. +- `pnpm exec playwright test tests/e2e/pi-coding-first-chat.spec.ts`: passed, + 1/1. The post-review real-image fixture also passed 5/5 fresh-userData repeats + with one upload, Host readback preview, optimistic message, HTTP 202 and no + base64 in later payloads. The earlier text-only first-Conversation fixture + passed 20/20 fresh-userData runs below the 500 ms editability budget. +- `git diff --check`: passed; only repository line-ending conversion notices + were printed. - Real Provider remains **Explicitly Waived / Accepted Risk** with `realTurnVerified=false`; no Provider Account, credential isolation, protocol-specific endpoint, rate-limit or real concurrency behavior is diff --git a/README.md b/README.md index 63c6c50..a5200f4 100644 --- a/README.md +++ b/README.md @@ -128,7 +128,7 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode - 核心聊天页左侧按伙伴展示本地 Conversation,并提供新建入口;首次选择没有 Conversation 的伙伴时立即创建本地元数据,同时异步准备对应运行时。即使准备被阻塞或超时,输入框仍可编辑,草稿也不会丢失。 - Conversation 历史按需从 Main-owned Snapshot 读取。公开 SSE 只交付 Snapshot 与按 Conversation、worker generation 分组的 `patch-batch`;Renderer 整批校验连续 seq 后在一次状态事务中顺序应用,缺口或畸形批次只恢复目标 Conversation,隐藏 Conversation 的流式更新不会提交选中时间线。 - 核心时间线渲染消息、Markdown、thinking、工具、压缩、轮次边界和通知;默认保留最近 120 个节点的渲染窗口,可按 100 个节点加载更早内容。工具累计输出覆盖同一块而不重复追加。 -- Composer 支持文字、粘贴或选择 PNG/JPEG/WebP/GIF 图片。图片在发送前只保留本地预览,点击发送时才经 Main-owned 有界二进制接口上传一次;状态与事件只保存 attachment id,时间线按需读取二进制并创建临时 object URL,不保存重复 base64。 +- 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 操作当前不属于核心聊天页。 diff --git a/electron/api/coding-composition.ts b/electron/api/coding-composition.ts index ba6c9c7..2f59c53 100644 --- a/electron/api/coding-composition.ts +++ b/electron/api/coding-composition.ts @@ -81,7 +81,18 @@ export function createCodingComposition( }); const extensionHost = new PiManagedExtensionHost(); extensionHost.configureProductTools(productTools); - const registry = new PiSessionRegistry({ projectStore }); + const conversationStores = new Map>(); + const conversationStoreForProject = (projectPath: string) => { + const existing = conversationStores.get(projectPath); + if (existing) return existing; + const created = createCodingConversationStore(projectPath); + conversationStores.set(projectPath, created); + return created; + }; + const registry = new PiSessionRegistry({ + projectStore, + createConversationStore: conversationStoreForProject, + }); const revisions = new PiManagedInputRevisionCoordinator(); const processBudget = new PiProcessBudget(); const loadProviderInput = async () => ({ @@ -145,15 +156,16 @@ export function createCodingComposition( })), }); const projects = new CodingProjectService(projectStore, { + createConversationStore: conversationStoreForProject, onResourcesChanged: async (project) => { runtime.markResourcesStale(); - const conversations = await createCodingConversationStore(project.path).read() + const conversations = await conversationStoreForProject(project.path).read() .then((file) => file.conversations) .catch(() => []); for (const conversation of conversations) registry.forget(conversation.id); }, onProjectDeactivated: async (project) => { - const conversations = await createCodingConversationStore(project.path).read() + const conversations = await conversationStoreForProject(project.path).read() .then((file) => file.conversations) .catch(() => []); await Promise.allSettled([ diff --git a/electron/api/routes/coding-attachments.ts b/electron/api/routes/coding-attachments.ts index 1eeb755..4dee2a3 100644 --- a/electron/api/routes/coding-attachments.ts +++ b/electron/api/routes/coding-attachments.ts @@ -3,6 +3,7 @@ import type { HostApiContext } from '../context'; import { sendJson } from '../route-utils'; const MAX_ATTACHMENT_BYTES = 16 * 1024 * 1024; +const ATTACHMENT_ID_PATTERN = /^[A-Za-z0-9-]{1,64}$/; const SUPPORTED_IMAGE_MIMES = new Set([ 'image/png', 'image/jpeg', @@ -10,6 +11,37 @@ const SUPPORTED_IMAGE_MIMES = new Set([ 'image/gif', ]); +function matchesImageSignature(data: Uint8Array, mime: string): boolean { + if (mime === 'image/png') { + return data.length >= 8 + && data[0] === 0x89 + && data[1] === 0x50 + && data[2] === 0x4e + && data[3] === 0x47 + && data[4] === 0x0d + && data[5] === 0x0a + && data[6] === 0x1a + && data[7] === 0x0a; + } + if (mime === 'image/jpeg') { + return data.length >= 3 + && data[0] === 0xff + && data[1] === 0xd8 + && data[2] === 0xff; + } + if (mime === 'image/gif') { + if (data.length < 6) return false; + const header = Buffer.from(data.subarray(0, 6)).toString('ascii'); + return header === 'GIF87a' || header === 'GIF89a'; + } + if (mime === 'image/webp') { + return data.length >= 12 + && Buffer.from(data.subarray(0, 4)).toString('ascii') === 'RIFF' + && Buffer.from(data.subarray(8, 12)).toString('ascii') === 'WEBP'; + } + return false; +} + function contentType(req: IncomingMessage): string { const value = req.headers['content-type']; return (Array.isArray(value) ? value[0] : value)?.split(';', 1)[0]?.trim().toLowerCase() ?? ''; @@ -17,7 +49,7 @@ function contentType(req: IncomingMessage): string { async function readBoundedBody(req: IncomingMessage): Promise { const declared = Number(req.headers['content-length']); - if (Number.isFinite(declared) && (declared <= 0 || declared > MAX_ATTACHMENT_BYTES)) { + if (Number.isFinite(declared) && declared > MAX_ATTACHMENT_BYTES) { throw Object.assign(new Error('Attachment size is invalid'), { status: 413 }); } const chunks: Buffer[] = []; @@ -86,12 +118,24 @@ export async function handleCodingAttachmentRoutes( if (!SUPPORTED_IMAGE_MIMES.has(mime)) { throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 }); } - const attachment = await attachments.put(await readBoundedBody(req), mime); + const body = await readBoundedBody(req); + if (!matchesImageSignature(body, mime)) { + throw Object.assign(new Error('Attachment body is not the declared image type'), { status: 400 }); + } + const attachment = await attachments.put(body, mime); sendJson(res, 201, attachment); return true; } - const attachmentId = decodeURIComponent(contentMatch?.[1] ?? ''); + let attachmentId: string; + try { + attachmentId = decodeURIComponent(contentMatch?.[1] ?? ''); + } catch { + throw Object.assign(new Error('Attachment id is invalid'), { status: 400 }); + } + if (!ATTACHMENT_ID_PATTERN.test(attachmentId)) { + throw Object.assign(new Error('Attachment id is invalid'), { status: 400 }); + } const record = await attachments.read(attachmentId); if (!SUPPORTED_IMAGE_MIMES.has(record.mime)) { throw Object.assign(new Error('Attachment MIME is invalid'), { status: 400 }); diff --git a/electron/api/server.ts b/electron/api/server.ts index 094410e..d271b2a 100644 --- a/electron/api/server.ts +++ b/electron/api/server.ts @@ -56,9 +56,12 @@ export function startHostApiServer(ctx: HostApiContext, port = getPort('NIANCODE } // ── Content-Type gate (anti-CSRF) ────────────────────────── - // Mutation requests must use application/json to force a CORS - // preflight, preventing "simple request" CSRF attacks. - if (!requireJsonContentType(req)) { + // Mutation requests use application/json, except the exact authenticated + // image-byte upload route. Its supported image MIME types also force a + // browser preflight and are validated before storage. + const isCodingAttachmentUpload = req.method === 'POST' + && requestUrl.pathname === '/api/coding/attachments'; + if (!isCodingAttachmentUpload && !requireJsonContentType(req)) { sendJson(res, 415, { success: false, error: 'Content-Type must be application/json' }); return; } diff --git a/electron/coding-projects/project-service.ts b/electron/coding-projects/project-service.ts index eea87db..d159128 100644 --- a/electron/coding-projects/project-service.ts +++ b/electron/coding-projects/project-service.ts @@ -56,6 +56,7 @@ export interface CodingProjectConfigSnapshot { export interface CodingProjectServiceOptions { onResourcesChanged?(project: CodingProject): Promise | void; onProjectDeactivated?(project: CodingProject): Promise | void; + createConversationStore?: typeof createCodingConversationStore; writeConfig?: typeof writeCodingProjectConfigV2; } @@ -299,7 +300,7 @@ export class CodingProjectService { conversationStore(projectPath: string): ReturnType { const existing = this.conversationStores.get(projectPath); if (existing) return existing; - const created = createCodingConversationStore(projectPath); + const created = (this.options.createConversationStore ?? createCodingConversationStore)(projectPath); this.conversationStores.set(projectPath, created); return created; } diff --git a/src/lib/coding-attachments.ts b/src/lib/coding-attachments.ts index 13d8631..229baa9 100644 --- a/src/lib/coding-attachments.ts +++ b/src/lib/coding-attachments.ts @@ -1,6 +1,8 @@ import { hostApiFetch, hostApiFetchBytes } from './host-api'; export const CODING_ATTACHMENT_MAX_BYTES = 16 * 1024 * 1024; +export const CODING_ATTACHMENT_MAX_COUNT = 16; +export const CODING_ATTACHMENT_UPLOAD_CONCURRENCY = 4; export const CODING_ATTACHMENT_MIMES = new Set([ 'image/png', 'image/jpeg', diff --git a/src/pages/Chat/CodingChatPanel.tsx b/src/pages/Chat/CodingChatPanel.tsx index 88c14a8..749688c 100644 --- a/src/pages/Chat/CodingChatPanel.tsx +++ b/src/pages/Chat/CodingChatPanel.tsx @@ -10,7 +10,9 @@ import { import { Button } from '@/components/ui/button'; import { CODING_ATTACHMENT_MAX_BYTES, + CODING_ATTACHMENT_MAX_COUNT, CODING_ATTACHMENT_MIMES, + CODING_ATTACHMENT_UPLOAD_CONCURRENCY, uploadCodingAttachment, type CodingAttachmentRef, } from '@/lib/coding-attachments'; @@ -23,7 +25,7 @@ import { useCodingConversationStore, type CodingConversationStoreState, } from '@/stores/coding-conversations'; -import { useCodingWorkspaceStore } from '@/stores/coding-workspace'; +import { codingWorkspaceStore, useCodingWorkspaceStore } from '@/stores/coding-workspace'; import type { CodingDraftAttachment, } from '@/types/coding-conversation'; @@ -66,6 +68,7 @@ function requestCount( } const BLOCKING_REQUEST_STATUSES = new Set(['pending', 'accepted']); +const SUBMITTING_REQUEST_STATUSES = new Set(['pending']); const ACCEPTED_REQUEST_STATUSES = new Set(['accepted']); const UNCERTAIN_REQUEST_STATUSES = new Set(['uncertain']); @@ -96,10 +99,8 @@ export function CodingChatPanel({ const submitPrompt = useCodingConversationStore((state) => state.submitPrompt); const recoverConversation = useCodingConversationStore((state) => state.recoverConversation); - const [provisionalDraft, setProvisionalDraft] = useState(''); - const [creatingNewConversation, setCreatingNewConversation] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [submissionError, setSubmissionError] = useState(null); + const [provisionalDrafts, setProvisionalDrafts] = useState>({}); + const [submissionErrors, setSubmissionErrors] = useState>({}); const [attachmentsByDraftKey, setAttachmentsByDraftKey] = useState< Record >({}); @@ -108,6 +109,7 @@ export function CodingChatPanel({ const attachmentsRef = useRef(attachmentsByDraftKey); const uploadedAttachmentsRef = useRef(new Map()); const uploadFlightsRef = useRef(new Map>()); + const submissionFlightsRef = useRef(new Set()); const agents = useMemo(() => ( config?.agents.filter((agent) => agent.enabled && !agent.archivedAt) ?? [] @@ -122,11 +124,16 @@ export function CodingChatPanel({ ), [conversations, selectedAgent]); const selectedConversation = conversations.find((conversation) => ( conversation.id === selectedConversationId + && conversation.agentId === selectedAgent?.id + && !conversation.archivedAt )) ?? null; + const targetConversationId = selectedConversation?.id ?? null; const provisionalDraftKey = activeProject && selectedAgent ? `new:${activeProject.id}:${selectedAgent.id}` : null; - const draftKey = selectedConversationId ?? provisionalDraftKey; + const draftKey = targetConversationId ?? provisionalDraftKey; + const provisionalDraft = provisionalDraftKey ? provisionalDrafts[provisionalDraftKey] ?? '' : ''; + const submissionError = draftKey ? submissionErrors[draftKey] ?? null : null; const localAttachments = useMemo(() => ( draftKey ? attachmentsByDraftKey[draftKey] ?? [] : [] ), [attachmentsByDraftKey, draftKey]); @@ -134,42 +141,47 @@ export function CodingChatPanel({ attachmentsRef.current = attachmentsByDraftKey; const selectDraft = useCallback((state: CodingConversationStoreState) => ( - selectedConversationId - ? selectCodingConversationDraft(selectedConversationId)(state) + targetConversationId + ? selectCodingConversationDraft(targetConversationId)(state) : null - ), [selectedConversationId]); + ), [targetConversationId]); const selectedDraft = useCodingConversationStore(selectDraft); const selectSnapshot = useCallback((state: CodingConversationStoreState) => ( - selectedConversationId - ? selectCodingConversationSnapshot(selectedConversationId)(state) + targetConversationId + ? selectCodingConversationSnapshot(targetConversationId)(state) : null - ), [selectedConversationId]); + ), [targetConversationId]); const snapshot = useCodingConversationStore(selectSnapshot); const entryLoadState = useCodingConversationStore((state) => ( - selectedConversationId - ? state.entriesByConversationId[selectedConversationId]?.loadState ?? 'empty' + targetConversationId + ? state.entriesByConversationId[targetConversationId]?.loadState ?? 'empty' : 'empty' )); const entryError = useCodingConversationStore((state) => ( - selectedConversationId - ? state.entriesByConversationId[selectedConversationId]?.error ?? null + targetConversationId + ? state.entriesByConversationId[targetConversationId]?.error ?? null : null )); const blockingRequestCount = useCodingConversationStore((state) => requestCount( state, - selectedConversationId, + targetConversationId, BLOCKING_REQUEST_STATUSES, )); const acceptedRequestCount = useCodingConversationStore((state) => requestCount( state, - selectedConversationId, + targetConversationId, ACCEPTED_REQUEST_STATUSES, )); const uncertainRequestCount = useCodingConversationStore((state) => requestCount( state, - selectedConversationId, + targetConversationId, UNCERTAIN_REQUEST_STATUSES, )); + const submittingRequestCount = useCodingConversationStore((state) => requestCount( + state, + targetConversationId, + SUBMITTING_REQUEST_STATUSES, + )); useEffect(() => { void loadWorkspace().catch(() => undefined); @@ -207,7 +219,11 @@ export function CodingChatPanel({ void ensureConversation(selectedAgent.id) .then((conversation) => { primeConversation(createLocalConversationSnapshot(activeProject.id, conversation)); - void selectConversation(conversation.id).catch(() => undefined); + const current = codingWorkspaceStore.getState(); + if (current.activeProjectId === activeProject.id + && current.selectedAgentId === selectedAgent.id) { + void selectConversation(conversation.id).catch(() => undefined); + } }) .catch(() => undefined); }, [ @@ -225,30 +241,38 @@ export function CodingChatPanel({ const nextDraft = navigationDraft?.trim(); if (!nextDraft || appliedNavigationDraftRef.current === nextDraft) return; appliedNavigationDraftRef.current = nextDraft; - if (selectedConversationId) { - setConversationDraft(selectedConversationId, nextDraft); + if (targetConversationId) { + setConversationDraft(targetConversationId, nextDraft); + } else if (provisionalDraftKey) { + setProvisionalDrafts((current) => ({ ...current, [provisionalDraftKey]: nextDraft })); } else { - setProvisionalDraft(nextDraft); + return; } - }, [navigationDraft, selectedConversationId, setConversationDraft]); + }, [navigationDraft, provisionalDraftKey, setConversationDraft, targetConversationId]); useEffect(() => { - if (!selectedConversationId) return; + if (!targetConversationId) return; if (provisionalDraft.trim()) { - const current = codingConversationStore.getState().draftsByConversationId[selectedConversationId]; - if (!current?.text) setConversationDraft(selectedConversationId, provisionalDraft); - setProvisionalDraft(''); + const current = codingConversationStore.getState().draftsByConversationId[targetConversationId]; + if (!current?.text) setConversationDraft(targetConversationId, provisionalDraft); + if (provisionalDraftKey) { + setProvisionalDrafts((drafts) => { + const next = { ...drafts }; + delete next[provisionalDraftKey]; + return next; + }); + } } if (provisionalDraftKey) { setAttachmentsByDraftKey((currentAttachments) => { const pending = currentAttachments[provisionalDraftKey]; - if (!pending?.length || currentAttachments[selectedConversationId]?.length) return currentAttachments; - const next = { ...currentAttachments, [selectedConversationId]: pending }; + if (!pending?.length || currentAttachments[targetConversationId]?.length) return currentAttachments; + const next = { ...currentAttachments, [targetConversationId]: pending }; delete next[provisionalDraftKey]; return next; }); } - }, [provisionalDraft, provisionalDraftKey, selectedConversationId, setConversationDraft]); + }, [provisionalDraft, provisionalDraftKey, setConversationDraft, targetConversationId]); const handleSelectConversation = useCallback((conversation: CodingConversationMetadata) => { if (!activeProject) return; @@ -257,19 +281,20 @@ export function CodingChatPanel({ }, [activeProject, primeConversation, selectConversation]); const handleCreateConversation = useCallback(async () => { - if (!activeProject || !selectedAgent || creatingNewConversation) return; - setCreatingNewConversation(true); - try { - const conversation = await createConversation(selectedAgent.id); - primeConversation(createLocalConversationSnapshot(activeProject.id, conversation)); + if (!activeProject || !selectedAgent || creatingAgentIds[selectedAgent.id]) return; + const projectId = activeProject.id; + const agentId = selectedAgent.id; + const conversation = await createConversation(agentId).catch(() => null); + if (!conversation) return; + primeConversation(createLocalConversationSnapshot(projectId, conversation)); + const current = codingWorkspaceStore.getState(); + if (current.activeProjectId === projectId && current.selectedAgentId === agentId) { await selectConversation(conversation.id).catch(() => undefined); - } finally { - setCreatingNewConversation(false); } }, [ activeProject, createConversation, - creatingNewConversation, + creatingAgentIds, primeConversation, selectConversation, selectedAgent, @@ -278,14 +303,18 @@ export function CodingChatPanel({ const handleAddFiles = useCallback((files: File[]) => { if (!draftKey) return; const accepted: LocalComposerAttachment[] = []; - for (const file of files) { + const remaining = Math.max(0, CODING_ATTACHMENT_MAX_COUNT - localAttachments.length); + let validationError = files.length > remaining + ? `每条消息最多添加 ${CODING_ATTACHMENT_MAX_COUNT} 张图片。` + : null; + for (const file of files.slice(0, remaining)) { const mime = file.type.trim().toLowerCase(); if (!CODING_ATTACHMENT_MIMES.has(mime)) { - setSubmissionError('仅支持 PNG、JPEG、WebP 或 GIF 图片。'); + validationError = '仅支持 PNG、JPEG、WebP 或 GIF 图片。'; continue; } if (file.size <= 0 || file.size > CODING_ATTACHMENT_MAX_BYTES) { - setSubmissionError('图片不能超过 16 MB。'); + validationError = '图片不能超过 16 MB。'; continue; } accepted.push({ @@ -295,13 +324,18 @@ export function CodingChatPanel({ previewUrl: URL.createObjectURL(file), }); } + setSubmissionErrors((current) => { + const next = { ...current }; + if (validationError) next[draftKey] = validationError; + else delete next[draftKey]; + return next; + }); if (accepted.length === 0) return; - setSubmissionError(null); setAttachmentsByDraftKey((current) => ({ ...current, [draftKey]: [...(current[draftKey] ?? []), ...accepted], })); - }, [draftKey]); + }, [draftKey, localAttachments.length]); const handleRemoveAttachment = useCallback((id: string) => { if (!draftKey) return; @@ -319,37 +353,54 @@ export function CodingChatPanel({ const prepareAttachments = useCallback(async ( attachments: LocalComposerAttachment[], - ): Promise => await Promise.all(attachments.map(async (attachment) => { - let uploaded = uploadedAttachmentsRef.current.get(attachment.id); - if (!uploaded) { - let flight = uploadFlightsRef.current.get(attachment.id); - if (!flight) { - flight = uploadCodingAttachment(attachment.file); - uploadFlightsRef.current.set(attachment.id, flight); - } - try { - uploaded = await flight; - uploadedAttachmentsRef.current.set(attachment.id, uploaded); - } finally { - if (uploadFlightsRef.current.get(attachment.id) === flight) { - uploadFlightsRef.current.delete(attachment.id); + ): Promise => { + const prepared = new Array(attachments.length); + let nextIndex = 0; + const workers = Array.from({ + length: Math.min(CODING_ATTACHMENT_UPLOAD_CONCURRENCY, attachments.length), + }, async () => { + while (nextIndex < attachments.length) { + const index = nextIndex++; + const attachment = attachments[index]; + let uploaded = uploadedAttachmentsRef.current.get(attachment.id); + if (!uploaded) { + let flight = uploadFlightsRef.current.get(attachment.id); + if (!flight) { + flight = uploadCodingAttachment(attachment.file); + uploadFlightsRef.current.set(attachment.id, flight); + } + try { + uploaded = await flight; + uploadedAttachmentsRef.current.set(attachment.id, uploaded); + } finally { + if (uploadFlightsRef.current.get(attachment.id) === flight) { + uploadFlightsRef.current.delete(attachment.id); + } + } } + prepared[index] = { + attachmentId: uploaded.attachmentId, + mime: uploaded.mime, + previewUrl: attachment.previewUrl, + }; } - } - return { - attachmentId: uploaded.attachmentId, - mime: uploaded.mime, - previewUrl: attachment.previewUrl, - }; - })), []); + }); + await Promise.all(workers); + return prepared; + }, []); const handleSubmit = useCallback(() => { - if (!selectedConversationId || !draftKey || submitting) return; + if (!targetConversationId || !draftKey || submissionFlightsRef.current.has(targetConversationId)) return; + const conversationId = targetConversationId; const attachments = [...localAttachments]; - setSubmitting(true); - setSubmissionError(null); + submissionFlightsRef.current.add(conversationId); + setSubmissionErrors((current) => { + const next = { ...current }; + delete next[draftKey]; + return next; + }); void submitPrompt({ - conversationId: selectedConversationId, + conversationId, mode: 'prompt', prepareAttachments: attachments.length > 0 ? () => prepareAttachments(attachments) @@ -365,17 +416,19 @@ export function CodingChatPanel({ uploadedAttachmentsRef.current.delete(attachment.id); } }).catch((error) => { - setSubmissionError(error instanceof Error ? error.message : String(error)); + setSubmissionErrors((current) => ({ + ...current, + [draftKey]: error instanceof Error ? error.message : String(error), + })); }).finally(() => { - setSubmitting(false); + submissionFlightsRef.current.delete(conversationId); }); }, [ draftKey, localAttachments, prepareAttachments, - selectedConversationId, + targetConversationId, submitPrompt, - submitting, ]); const runStatus = snapshot?.run.status ?? 'idle'; @@ -392,10 +445,10 @@ export function CodingChatPanel({ const editable = Boolean(activeProject && selectedAgent); const canSend = Boolean( editable - && selectedConversationId + && targetConversationId && (draft.trim() || localAttachments.length > 0) && !running - && !submitting + && submittingRequestCount === 0 && !preparationError && blockingRequestCount === 0 && uncertainRequestCount === 0, @@ -439,7 +492,10 @@ export function CodingChatPanel({ 'flex min-h-10 w-full items-center gap-2 rounded-xl px-2.5 text-left text-sm transition-[background-color,scale] duration-150 ease-out active:scale-[0.96]', selectedAgent?.id === agent.id ? 'bg-background font-medium shadow-soft' : 'hover:bg-background/70', )} - onClick={() => selectAgent(agent.id)} + onClick={() => { + clearConversationSelection(); + selectAgent(agent.id); + }} > {agent.name.trim().slice(0, 1) || 'A'} @@ -456,10 +512,10 @@ export function CodingChatPanel({ size="icon" className="h-10 w-10 rounded-xl transition-transform duration-150 ease-out active:scale-[0.96]" aria-label="新建对话" - disabled={!selectedAgent || creatingNewConversation} + disabled={!selectedAgent || Boolean(selectedAgent && creatingAgentIds[selectedAgent.id])} onClick={() => void handleCreateConversation()} > - {creatingNewConversation + {selectedAgent && creatingAgentIds[selectedAgent.id] ?