Q139: each conversation keeps its own draft text and attachment
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
c5b011ce80
commit
46f7561f77
5 files changed
+71
-25
No files matched your search
@@ -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<string | null>(null);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [attachmentFileId, setAttachmentFileId] = useState<string>("");
|
||||
// Text and attachment are kept per conversation, so switching neither loses one nor carries the other along.
|
||||
const [drafts, setDrafts] = useState<Record<string, Draft>>({});
|
||||
const updateDraft = useCallback((spaceId: string, change: Partial<Draft>) =>
|
||||
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<Record<string, SendState>>({});
|
||||
const composerRef = useRef<HTMLTextAreaElement | null>(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")}
|
||||
|
||||
@@ -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<HTMLFormElement>) => 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<HTMLTextAreaElement | null>(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 (
|
||||
<form onSubmit={onSubmit} style={{ padding: 14, paddingTop: 4, flexShrink: 0 }}>
|
||||
<form onSubmit={onSubmit} data-space-id={spaceId ?? undefined} style={{ padding: 14, paddingTop: 4, flexShrink: 0 }}>
|
||||
<div style={{ border: "1px solid var(--border-strong)", borderRadius: 12, background: "var(--panel)", padding: "10px 12px" }}>
|
||||
<MentionTextarea
|
||||
name="message"
|
||||
defaultValue={draft.body}
|
||||
rows={2}
|
||||
disabled={locked}
|
||||
ariaLabel={t("Message")}
|
||||
|
||||
@@ -9,7 +9,7 @@ import type {
|
||||
ChatSlashInvocationRecord,
|
||||
FileObjectRecord,
|
||||
} from "../../../../shared/domain";
|
||||
import { ChatComposer, type SendState } from "./ChatComposer";
|
||||
import { ChatComposer, type Draft, type SendState } from "./ChatComposer";
|
||||
import { ChatTimeline } from "./ChatTimeline";
|
||||
import type { MessageContext } from "./chat-helpers";
|
||||
import { t } from "../../i18n/i18n";
|
||||
@@ -24,7 +24,8 @@ export function ChatConversation({
|
||||
slashInvocations,
|
||||
files,
|
||||
context,
|
||||
attachmentFileId,
|
||||
draft,
|
||||
onDraftLeave,
|
||||
currentEmployeeId,
|
||||
onAttachmentChange,
|
||||
onAttach,
|
||||
@@ -46,7 +47,8 @@ export function ChatConversation({
|
||||
slashInvocations: ChatSlashInvocationRecord[];
|
||||
files: FileObjectRecord[];
|
||||
context: MessageContext;
|
||||
attachmentFileId: string;
|
||||
draft: Draft;
|
||||
onDraftLeave: (spaceId: string, body: string) => 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}
|
||||
|
||||
@@ -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({
|
||||
<textarea
|
||||
ref={ref}
|
||||
name={name}
|
||||
defaultValue={defaultValue}
|
||||
rows={rows}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
|
||||
@@ -108,6 +108,16 @@ test("the pinned CEO conversation is the Messages front door", async () => {
|
||||
await expect(chatLog.getByText("Second thought: what is blocked?")).toBeVisible();
|
||||
await expect(composer).toHaveValue("");
|
||||
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||
|
||||
// Each conversation keeps its own unsent draft.
|
||||
await composer.fill("Draft for the CEO only");
|
||||
await page.locator(".convo-item", { hasText: "general" }).click();
|
||||
await expect(composer).toHaveValue("");
|
||||
await composer.fill("Draft for general");
|
||||
await page.locator(".convo-item").first().click();
|
||||
await expect(composer).toHaveValue("Draft for the CEO only");
|
||||
await page.locator(".convo-item", { hasText: "general" }).click();
|
||||
await expect(composer).toHaveValue("Draft for general");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user