Q140: a message can carry several files, or files alone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
46f7561f77
commit
a6ab09d6b5
11 files changed
+129
-35
No files matched your search
@@ -37,6 +37,8 @@ export const zh: Record<string, string> = {
|
||||
"Waiting on you, or resumes on its own at {time}.": "等待你处理,或于 {time} 自动恢复。",
|
||||
"Waiting on you.": "等待你处理。",
|
||||
"Not sent: {reason}": "未发送:{reason}",
|
||||
"Remove {name}": "移除 {name}",
|
||||
"Not attached: {reason}": "未能附加:{reason}",
|
||||
"Resume": "恢复",
|
||||
"Missing this run": "本次运行缺少的能力",
|
||||
"Disconnect": "断开连接",
|
||||
@@ -420,7 +422,6 @@ export const zh: Record<string, string> = {
|
||||
"Region": "区域",
|
||||
"Rejected": "已驳回",
|
||||
"Reload models": "重新加载模型",
|
||||
"Remove attachment": "移除附件",
|
||||
"Reporting lines": "汇报关系",
|
||||
"Request ID": "请求 ID",
|
||||
"Request instructions": "请求指令",
|
||||
|
||||
@@ -22,7 +22,7 @@ function value(form: FormData, key: string): string {
|
||||
}
|
||||
|
||||
const IDLE_SEND: SendState = { pending: false, error: null };
|
||||
const EMPTY_DRAFT: Draft = { body: "", attachmentFileId: "" };
|
||||
const EMPTY_DRAFT: Draft = { body: "", attachmentFileIds: [] };
|
||||
|
||||
export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
@@ -72,6 +72,7 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
// 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 [attachErrors, setAttachErrors] = useState<Record<string, string | null>>({});
|
||||
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]);
|
||||
@@ -119,9 +120,9 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
event.preventDefault();
|
||||
if (!activeSpace || sends[activeSpace.id]?.pending) return;
|
||||
const spaceId = activeSpace.id;
|
||||
const sentAttachment = (drafts[spaceId] ?? EMPTY_DRAFT).attachmentFileId;
|
||||
const sentAttachments = (drafts[spaceId] ?? EMPTY_DRAFT).attachmentFileIds;
|
||||
const body = value(new FormData(event.currentTarget), "message");
|
||||
if (!body) return;
|
||||
if (!body && sentAttachments.length === 0) 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.
|
||||
actions.run(() => window.agentCompany.sendMessage({
|
||||
@@ -131,14 +132,14 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
senderType: "employee",
|
||||
senderId: employee.id,
|
||||
bodyMd: body,
|
||||
attachmentFileIds: sentAttachment ? [sentAttachment] : [],
|
||||
attachmentFileIds: sentAttachments,
|
||||
})).then(() => {
|
||||
// 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 } };
|
||||
const kept = (current[spaceId] ?? EMPTY_DRAFT).attachmentFileIds.filter((fileId) => !sentAttachments.includes(fileId));
|
||||
return { ...current, [spaceId]: { body: "", attachmentFileIds: kept } };
|
||||
});
|
||||
setSends((current) => ({ ...current, [spaceId]: { pending: false, error: null } }));
|
||||
}).catch((error: unknown) => {
|
||||
@@ -198,8 +199,18 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
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) updateDraft(spaceId, { attachmentFileId: result.data });
|
||||
if (!result.ok) {
|
||||
setAttachErrors((current) => ({ ...current, [spaceId]: result.error.message }));
|
||||
return;
|
||||
}
|
||||
const { fileIds, failures } = result.data;
|
||||
setDrafts((current) => {
|
||||
const draft = current[spaceId] ?? EMPTY_DRAFT;
|
||||
const added = fileIds.filter((fileId) => !draft.attachmentFileIds.includes(fileId));
|
||||
return { ...current, [spaceId]: { ...draft, attachmentFileIds: [...draft.attachmentFileIds, ...added] } };
|
||||
});
|
||||
const reason = failures.map((failure) => `${failure.name} (${failure.reason})`).join("; ");
|
||||
setAttachErrors((current) => ({ ...current, [spaceId]: reason || null }));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -231,10 +242,13 @@ export function ChatPage({ snapshot, actions }: PageProps) {
|
||||
files={files}
|
||||
context={{ employees, agents, readersByMessage }}
|
||||
draft={(activeSpaceId ? drafts[activeSpaceId] : undefined) ?? EMPTY_DRAFT}
|
||||
attachError={(activeSpaceId ? attachErrors[activeSpaceId] : undefined) ?? null}
|
||||
onDraftLeave={saveDraftBody}
|
||||
currentEmployeeId={employee.id}
|
||||
onAttachmentChange={(fileId) => {
|
||||
if (activeSpaceId) updateDraft(activeSpaceId, { attachmentFileId: fileId });
|
||||
onAttachmentRemove={(fileId) => {
|
||||
if (!activeSpaceId) return;
|
||||
const remaining = ((drafts[activeSpaceId] ?? EMPTY_DRAFT).attachmentFileIds).filter((id) => id !== fileId);
|
||||
updateDraft(activeSpaceId, { attachmentFileIds: remaining });
|
||||
}}
|
||||
onAttach={attachFile}
|
||||
onApprove={(approvalId) => decideApproval(approvalId, "approved")}
|
||||
|
||||
@@ -44,6 +44,7 @@ export function FilesPage({ snapshot, actions }: PageProps) {
|
||||
const upload = () => {
|
||||
void window.agentCompany.importFile({ orgId }).then((result) => {
|
||||
if (!result.ok) throw new Error(result.error.message);
|
||||
if (result.data.failures.length > 0) throw new Error(result.data.failures.map((failure) => `${failure.name}: ${failure.reason}`).join("; "));
|
||||
});
|
||||
};
|
||||
const openFile = (file: FileObjectRecord) => {
|
||||
|
||||
@@ -11,22 +11,23 @@ export interface SendState {
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** A conversation's unsent message: its text and the file picked to go with it. */
|
||||
/** A conversation's unsent message: its text and the files picked to go with it. */
|
||||
export interface Draft {
|
||||
body: string;
|
||||
attachmentFileId: string;
|
||||
attachmentFileIds: string[];
|
||||
}
|
||||
|
||||
export function ChatComposer({
|
||||
disabled,
|
||||
send,
|
||||
draft,
|
||||
attachError,
|
||||
spaceId,
|
||||
onLeave,
|
||||
files,
|
||||
agents,
|
||||
employees,
|
||||
onAttachmentChange,
|
||||
onAttachmentRemove,
|
||||
onAttach,
|
||||
onSubmit,
|
||||
composerRef,
|
||||
@@ -34,13 +35,15 @@ export function ChatComposer({
|
||||
disabled: boolean;
|
||||
send: SendState;
|
||||
draft: Draft;
|
||||
/** Why some picked files could not be attached, or null. */
|
||||
attachError: string | null;
|
||||
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[];
|
||||
onAttachmentChange: (next: string) => void;
|
||||
onAttachmentRemove: (fileId: string) => void;
|
||||
onAttach: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
composerRef: MutableRefObject<HTMLTextAreaElement | null>;
|
||||
@@ -48,7 +51,7 @@ 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 === draft.attachmentFileId) ?? null;
|
||||
const attachments = draft.attachmentFileIds.map((fileId) => files.find((file) => file.id === fileId) ?? { id: fileId, name: fileId });
|
||||
const internalRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const ref = composerRef ?? internalRef;
|
||||
useEffect(() => {
|
||||
@@ -89,27 +92,28 @@ export function ChatComposer({
|
||||
<div className="row" style={{ paddingTop: 6, borderTop: "1px solid var(--border-soft)", gap: 4 }}>
|
||||
<IconButton icon="attach" label={t("Attach file")} onClick={onAttach} disabled={locked} />
|
||||
<IconButton icon="sparkle" label={t("Commands")} onClick={() => setSlashOpen((open) => !open)} disabled={locked} />
|
||||
{attachment && (
|
||||
<span className="pill" style={{ maxWidth: 220 }}>
|
||||
{attachments.map((attachment) => (
|
||||
<span key={attachment.id} className="pill" style={{ maxWidth: 220 }}>
|
||||
<Icon name="file" size={14} />
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{attachment.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("Remove attachment")}
|
||||
aria-label={t("Remove {name}", { name: attachment.name })}
|
||||
disabled={locked}
|
||||
onClick={() => onAttachmentChange("")}
|
||||
onClick={() => onAttachmentRemove(attachment.id)}
|
||||
style={{ display: "inline-flex", border: 0, background: "transparent", padding: 0, color: "var(--text-3)", cursor: "pointer" }}
|
||||
>
|
||||
<Icon name="x" size={14} />
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
))}
|
||||
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 6 }}>
|
||||
<span className="mono" style={{ fontSize: 12, color: "var(--text-3)" }}>Ctrl+Enter</span>
|
||||
<Button tone="primary" size="small" type="submit" disabled={locked}><Icon name="send" size={12} /> {t("Send")}</Button>
|
||||
</div>
|
||||
</div>
|
||||
{send.error && <div className="form-error" role="alert">{t("Not sent: {reason}", { reason: send.error })}</div>}
|
||||
{attachError && <div className="form-error" role="alert">{t("Not attached: {reason}", { reason: attachError })}</div>}
|
||||
{slashOpen && !locked && (
|
||||
<div style={{ marginTop: 8, display: "flex", flexWrap: "wrap", gap: 6 }}>
|
||||
{SLASH_COMMANDS.map((command) => (
|
||||
|
||||
@@ -25,9 +25,10 @@ export function ChatConversation({
|
||||
files,
|
||||
context,
|
||||
draft,
|
||||
attachError,
|
||||
onDraftLeave,
|
||||
currentEmployeeId,
|
||||
onAttachmentChange,
|
||||
onAttachmentRemove,
|
||||
onAttach,
|
||||
onApprove,
|
||||
onDecline,
|
||||
@@ -48,9 +49,10 @@ export function ChatConversation({
|
||||
files: FileObjectRecord[];
|
||||
context: MessageContext;
|
||||
draft: Draft;
|
||||
attachError: string | null;
|
||||
onDraftLeave: (spaceId: string, body: string) => void;
|
||||
currentEmployeeId: string;
|
||||
onAttachmentChange: (next: string) => void;
|
||||
onAttachmentRemove: (fileId: string) => void;
|
||||
onAttach: () => void;
|
||||
onApprove: (approvalId: string) => void;
|
||||
onDecline: (approvalId: string) => void;
|
||||
@@ -144,9 +146,10 @@ export function ChatConversation({
|
||||
agents={context.agents}
|
||||
employees={context.employees}
|
||||
draft={draft}
|
||||
attachError={attachError}
|
||||
spaceId={space?.id ?? null}
|
||||
onLeave={onDraftLeave}
|
||||
onAttachmentChange={onAttachmentChange}
|
||||
onAttachmentRemove={onAttachmentRemove}
|
||||
onAttach={onAttach}
|
||||
onSubmit={onSubmit}
|
||||
composerRef={composerRef}
|
||||
|
||||
Reference in new issue
Block a user