From 46f7561f773b10b58042b734a3be51c8854d44df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Thu, 8 Oct 2026 22:26:38 -0400 Subject: [PATCH] Q139: each conversation keeps its own draft text and attachment Co-Authored-By: Claude Opus 5.5 --- src/renderer/src/pages/ChatPage.tsx | 42 ++++++++++++------- src/renderer/src/pages/chat/ChatComposer.tsx | 29 ++++++++++--- .../src/pages/chat/ChatConversation.tsx | 12 ++++-- .../src/pages/chat/MentionTextarea.tsx | 3 ++ tests/e2e/ceo-chat.spec.ts | 10 +++++ 5 files changed, 71 insertions(+), 25 deletions(-) diff --git a/src/renderer/src/pages/ChatPage.tsx b/src/renderer/src/pages/ChatPage.tsx index 6f2b0acf..29575112 100644 --- a/src/renderer/src/pages/ChatPage.tsx +++ b/src/renderer/src/pages/ChatPage.tsx @@ -1,10 +1,10 @@ -import { useEffect, useMemo, useRef, useState, type FormEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type FormEvent } from "react"; import { Field } from "../shared/ui"; import { Dialog } from "../shared/Dialog"; import { t } from "../i18n/i18n"; import { ChatSidebar } from "./chat/ChatSidebar"; import { ChatConversation } from "./chat/ChatConversation"; -import type { SendState } from "./chat/ChatComposer"; +import type { Draft, SendState } from "./chat/ChatComposer"; import { buildConversations, collectParticipants, @@ -22,6 +22,7 @@ function value(form: FormData, key: string): string { } const IDLE_SEND: SendState = { pending: false, error: null }; +const EMPTY_DRAFT: Draft = { body: "", attachmentFileId: "" }; export function ChatPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); @@ -69,7 +70,11 @@ export function ChatPage({ snapshot, actions }: PageProps) { ); const [selectedSpaceId, setSelectedSpaceId] = useState(null); const [createOpen, setCreateOpen] = useState(false); - const [attachmentFileId, setAttachmentFileId] = useState(""); + // Text and attachment are kept per conversation, so switching neither loses one nor carries the other along. + const [drafts, setDrafts] = useState>({}); + const updateDraft = useCallback((spaceId: string, change: Partial) => + setDrafts((current) => ({ ...current, [spaceId]: { ...(current[spaceId] ?? EMPTY_DRAFT), ...change } })), []); + const saveDraftBody = useCallback((spaceId: string, body: string) => updateDraft(spaceId, { body }), [updateDraft]); // Sends are tracked per conversation, so a late answer lands in the conversation that sent it. const [sends, setSends] = useState>({}); const composerRef = useRef(null); @@ -114,9 +119,8 @@ export function ChatPage({ snapshot, actions }: PageProps) { event.preventDefault(); if (!activeSpace || sends[activeSpace.id]?.pending) return; const spaceId = activeSpace.id; - const sentAttachment = attachmentFileId; - const formElement = event.currentTarget; - const body = value(new FormData(formElement), "message"); + const sentAttachment = (drafts[spaceId] ?? EMPTY_DRAFT).attachmentFileId; + const body = value(new FormData(event.currentTarget), "message"); if (!body) return; setSends((current) => ({ ...current, [spaceId]: { pending: true, error: null } })); // The draft and attachment stay until the message is saved, so a refused send loses nothing. @@ -129,8 +133,13 @@ export function ChatPage({ snapshot, actions }: PageProps) { bodyMd: body, attachmentFileIds: sentAttachment ? [sentAttachment] : [], })).then(() => { - formElement.reset(); - setAttachmentFileId((current) => (current === sentAttachment ? "" : current)); + // Clear this conversation's mounted composer directly; reset() could restore the sent text as its default. + const node = composerRef.current; + if (node?.form?.dataset.spaceId === spaceId) node.value = ""; + setDrafts((current) => { + const kept = (current[spaceId] ?? EMPTY_DRAFT).attachmentFileId; + return { ...current, [spaceId]: { body: "", attachmentFileId: kept === sentAttachment ? "" : kept } }; + }); setSends((current) => ({ ...current, [spaceId]: { pending: false, error: null } })); }).catch((error: unknown) => { const reason = error instanceof Error ? error.message : String(error); @@ -186,13 +195,11 @@ export function ChatPage({ snapshot, actions }: PageProps) { })); const attachFile = () => { - importAttachment(setAttachmentFileId); - }; - - const importAttachment = (onSelect: (fileId: string) => void) => { - void window.agentCompany.importFile({ orgId, ...(activeSpaceId ? { spaceId: activeSpaceId } : {}) }).then((result) => { + if (!activeSpaceId) return; + const spaceId = activeSpaceId; + void window.agentCompany.importFile({ orgId, spaceId }).then((result) => { if (!result.ok) throw new Error(result.error.message); - if (result.data) onSelect(result.data); + if (result.data) updateDraft(spaceId, { attachmentFileId: result.data }); }); }; @@ -223,9 +230,12 @@ export function ChatPage({ snapshot, actions }: PageProps) { slashInvocations={slashInvocationsAll} files={files} context={{ employees, agents, readersByMessage }} - attachmentFileId={attachmentFileId} + draft={(activeSpaceId ? drafts[activeSpaceId] : undefined) ?? EMPTY_DRAFT} + onDraftLeave={saveDraftBody} currentEmployeeId={employee.id} - onAttachmentChange={setAttachmentFileId} + onAttachmentChange={(fileId) => { + if (activeSpaceId) updateDraft(activeSpaceId, { attachmentFileId: fileId }); + }} onAttach={attachFile} onApprove={(approvalId) => decideApproval(approvalId, "approved")} onDecline={(approvalId) => decideApproval(approvalId, "declined")} diff --git a/src/renderer/src/pages/chat/ChatComposer.tsx b/src/renderer/src/pages/chat/ChatComposer.tsx index c42dce2e..f46ab0a9 100644 --- a/src/renderer/src/pages/chat/ChatComposer.tsx +++ b/src/renderer/src/pages/chat/ChatComposer.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type FormEvent, type MutableRefObject } from "react"; +import { useEffect, useRef, useState, type FormEvent, type MutableRefObject } from "react"; import { Button, Icon, IconButton } from "../../shared/ui"; import type { AgentRecord, EmployeeRecord, FileObjectRecord } from "../../../../shared/domain"; import { SLASH_COMMANDS } from "./chat-helpers"; @@ -11,13 +11,21 @@ export interface SendState { error: string | null; } +/** A conversation's unsent message: its text and the file picked to go with it. */ +export interface Draft { + body: string; + attachmentFileId: string; +} + export function ChatComposer({ disabled, send, + draft, + spaceId, + onLeave, files, agents, employees, - attachmentFileId, onAttachmentChange, onAttach, onSubmit, @@ -25,10 +33,13 @@ export function ChatComposer({ }: { disabled: boolean; send: SendState; + draft: Draft; + spaceId: string | null; + /** Called with the unsent text when the composer goes away, so the conversation keeps it; must be stable. */ + onLeave: (spaceId: string, body: string) => void; files: FileObjectRecord[]; agents: AgentRecord[]; employees: EmployeeRecord[]; - attachmentFileId: string; onAttachmentChange: (next: string) => void; onAttach: () => void; onSubmit: (event: FormEvent) => void; @@ -37,9 +48,16 @@ export function ChatComposer({ const [slashOpen, setSlashOpen] = useState(false); // While a send is in flight nothing may change the draft it will clear on success. const locked = disabled || send.pending; - const attachment = files.find((file) => file.id === attachmentFileId) ?? null; + const attachment = files.find((file) => file.id === draft.attachmentFileId) ?? null; const internalRef = useRef(null); const ref = composerRef ?? internalRef; + useEffect(() => { + // The node is captured now: React detaches the ref before this cleanup runs. + const node = ref.current; + return () => { + if (node && spaceId) onLeave(spaceId, node.value); + }; + }, [ref, spaceId, onLeave]); const insert = (text: string) => { const node = ref.current; @@ -55,10 +73,11 @@ export function ChatComposer({ }; return ( -
+
void; currentEmployeeId: string; onAttachmentChange: (next: string) => void; onAttach: () => void; @@ -141,7 +143,9 @@ export function ChatConversation({ files={files} agents={context.agents} employees={context.employees} - attachmentFileId={attachmentFileId} + draft={draft} + spaceId={space?.id ?? null} + onLeave={onDraftLeave} onAttachmentChange={onAttachmentChange} onAttach={onAttach} onSubmit={onSubmit} diff --git a/src/renderer/src/pages/chat/MentionTextarea.tsx b/src/renderer/src/pages/chat/MentionTextarea.tsx index 3e74f2ca..ecadb095 100644 --- a/src/renderer/src/pages/chat/MentionTextarea.tsx +++ b/src/renderer/src/pages/chat/MentionTextarea.tsx @@ -19,6 +19,7 @@ function buildCandidates(agents: AgentRecord[], employees: EmployeeRecord[]): Me export function MentionTextarea({ name, + defaultValue, rows, disabled, ariaLabel, @@ -28,6 +29,7 @@ export function MentionTextarea({ style, }: { name: string; + defaultValue?: string; rows: number; disabled?: boolean; ariaLabel: string; @@ -152,6 +154,7 @@ export function MentionTextarea({