Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -3405,6 +3405,37 @@ body {
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
}

.chat-queue-edit {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 2px;
background: none;
border: none;
color: var(--text-muted);
cursor: pointer;
border-radius: var(--radius-sm, 6px);
}

.chat-queue-edit:hover {
color: var(--text-primary);
background: var(--bg-hover, rgba(255, 255, 255, 0.08));
}

.chat-queue-edit-input {
flex: 1;
min-width: 0;
padding: 2px 6px;
font-family: var(--font-sans);
font-size: 12px;
color: var(--text-primary);
background: var(--bg-primary);
border: 1px solid var(--border-focus, var(--accent));
border-radius: var(--radius-sm, 4px);
outline: none;
}

/* Input area */
.chat-input-area {
position: relative;
Expand Down
6 changes: 6 additions & 0 deletions src/renderer/src/screens/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,11 @@ function Chat({
setQueuedMessages([...queueRef.current]);
}, []);

const handleEditQueued = useCallback((index: number, newText: string) => {
queueRef.current[index] = { ...queueRef.current[index], text: newText };
setQueuedMessages([...queueRef.current]);
}, []);

const handleSubmitOrQueue = useCallback(
(text: string, attachments: Attachment[]) => {
if (isLoading) {
Expand Down Expand Up @@ -573,6 +578,7 @@ function Chat({
<QueuedMessages
messages={queuedMessages}
onRemove={handleRemoveQueued}
onEdit={handleEditQueued}
/>
<ChatInput
ref={chatInputRef}
Expand Down
121 changes: 99 additions & 22 deletions src/renderer/src/screens/Chat/QueuedMessages.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { memo, useEffect, useState } from "react";
import { CircleDashed, ChevronRight, ChevronDown, X } from "lucide-react";
import { memo, useEffect, useState, useRef, useCallback } from "react";
import { CircleDashed, ChevronRight, ChevronDown, X, Pencil } from "lucide-react";
import { useI18n } from "../../components/useI18n";
import type { Attachment } from "../../../../shared/attachments";

Expand All @@ -11,23 +11,55 @@
interface QueuedMessagesProps {
messages: QueuedMessage[];
onRemove: (index: number) => void;
onEdit: (index: number, newText: string) => void;
}

/**
* Pending-send queue indicator shown above the input while the agent is busy.
* Each queued message can be individually cancelled via an X button.
* Each queued message can be edited via a pencil button or cancelled via an X button.
*/
export const QueuedMessages = memo(function QueuedMessages({
messages,
onRemove,
onEdit,
}: QueuedMessagesProps): React.JSX.Element | null {
const { t } = useI18n();
const [expanded, setExpanded] = useState(false);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editText, setEditText] = useState("");
const editInputRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (messages.length === 0) setExpanded(false);
}, [messages.length]);

// Focus the edit input when entering edit mode
useEffect(() => {
if (editingIndex !== null && editInputRef.current) {
editInputRef.current.focus();
editInputRef.current.select();
}
}, [editingIndex]);

const startEdit = useCallback((index: number, text: string) => {
setEditingIndex(index);
setEditText(text);
}, []);

const commitEdit = useCallback((index: number) => {
const trimmed = editText.trim();
if (trimmed && trimmed !== messages[index].text) {
onEdit(index, trimmed);
}
setEditingIndex(null);
setEditText("");
}, [editText, messages, onEdit]);

const cancelEdit = useCallback(() => {
setEditingIndex(null);
setEditText("");
}, []);

if (messages.length === 0) return null;

const preview = (m: QueuedMessage): string => {
Expand All @@ -36,13 +68,42 @@
return t("chat.queuedAttachment", { count: m.attachments.length });
};

const renderEditInput = (index: number) => (

Check failure on line 71 in src/renderer/src/screens/Chat/QueuedMessages.tsx

View workflow job for this annotation

GitHub Actions / check

Missing return type on function
<input
ref={editInputRef}
type="text"
className="chat-queue-edit-input"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") commitEdit(index);
if (e.key === "Escape") cancelEdit();
}}
onBlur={() => commitEdit(index)}
/>
Comment on lines +78 to +83

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Escape key cancels but onBlur immediately re-commits

When a user presses Escape, cancelEdit() queues its state updates (setEditingIndex(null), setEditText("")) but React batches those updates — they haven't taken effect yet. The input then loses focus (unmounts on re-render), firing onBlurcommitEdit(index). At that point editText still holds the modified value, so the if (trimmed && trimmed !== messages[index].text) check passes and onEdit is called, silently saving changes the user explicitly rejected.

A common fix is a ref flag set synchronously before any state update so commitEdit can bail out early:

const isCancellingRef = useRef(false);

const cancelEdit = useCallback(() => {
  isCancellingRef.current = true;
  setEditingIndex(null);
  setEditText("");
}, []);

const commitEdit = useCallback((index: number) => {
  if (isCancellingRef.current) {
    isCancellingRef.current = false;
    return;
  }
  const trimmed = editText.trim();
  if (trimmed && trimmed !== messages[index].text) {
    onEdit(index, trimmed);
  }
  setEditingIndex(null);
  setEditText("");
}, [editText, messages, onEdit]);

);

if (messages.length === 1) {
const isEditing = editingIndex === 0;
return (
<div className="chat-queue-indicator">
<CircleDashed size={14} className="chat-queue-icon" />
<span className="chat-queue-single" title={preview(messages[0])}>
{preview(messages[0])}
</span>
{isEditing ? (
renderEditInput(0)
) : (
<span className="chat-queue-single" title={preview(messages[0])}>
{preview(messages[0])}
</span>
)}
<button
type="button"
className="chat-queue-edit"
onClick={() => startEdit(0, messages[0].text)}
aria-label={t("chat.queuedEdit")}
title={t("chat.queuedEdit")}
>
<Pencil size={12} />
</button>
Comment on lines +98 to +106

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Edit button shown for attachment-only messages

When messages[0].text === "" (an attachment-only queued message), startEdit(0, "") is called, opening an empty input. If the user types anything and commits, onEdit is called, converting what was an attachment-only message into one that also has text. The preview continues to show the attachment count (since preview prioritises text), so this silently changes the message type. Consider hiding or disabling the edit button when m.text === "".

<button
type="button"
className="chat-queue-remove"
Expand Down Expand Up @@ -70,23 +131,39 @@
</button>
{expanded && (
<ul className="chat-queue-list">
{messages.map((m, i) => (
<li
key={`${i}-${m.text.length}-${m.attachments.length}`}
className="chat-queue-item"
title={preview(m)}
>
<span className="chat-queue-item-text">{preview(m)}</span>
<button
type="button"
className="chat-queue-remove"
onClick={() => onRemove(i)}
aria-label={t("chat.queuedCancel")}
{messages.map((m, i) => {
const isEditing = editingIndex === i;
return (
<li
key={`${i}-${m.text.length}-${m.attachments.length}`}
className="chat-queue-item"
title={isEditing ? undefined : preview(m)}
>
<X size={12} />
</button>
</li>
))}
{isEditing ? (
renderEditInput(i)
) : (
<span className="chat-queue-item-text">{preview(m)}</span>
)}
<button
type="button"
className="chat-queue-edit"
onClick={() => startEdit(i, m.text)}
aria-label={t("chat.queuedEdit")}
title={t("chat.queuedEdit")}
>
<Pencil size={12} />
</button>
<button
type="button"
className="chat-queue-remove"
onClick={() => onRemove(i)}
aria-label={t("chat.queuedCancel")}
>
<X size={12} />
</button>
</li>
);
})}
</ul>
)}
</div>
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/en/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ export default {
queuedCount: "{{count}} queued",
queuedAttachment: "{{count}} attachment(s)",
queuedCancel: "Remove from queue",
queuedEdit: "Edit queued message",
worktree: {
loading: "Loading",
empty: "Folder is empty",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/es/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ export default {
},
queued: "{{count}} mensaje(s) en cola — se enviará cuando el agente termine",
queuedCancel: "Quitar de la cola",
queuedEdit: "Editar mensaje en cola",
worktree: {
loading: "Cargando",
empty: "La carpeta está vacía",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/id/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export default {
version: "Tampilkan versi Hermes",
},
queuedCancel: "Batalkan pesan antrian",
queuedEdit: "Edit pesan antrian",
worktree: {
loading: "Memuat",
empty: "Folder kosong",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/ja/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export default {
version: "Hermes バージョンを表示",
},
queuedCancel: "キューから削除",
queuedEdit: "キュー内のメッセージを編集",
worktree: {
loading: "読み込み中",
empty: "フォルダは空です",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/pl/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ export default {
queued:
"{{count}} wiadomość/wiadomości w kolejce — zostaną wysłane po zakończeniu pracy agenta",
queuedCancel: "Usuń z kolejki",
queuedEdit: "Edytuj wiadomość w kolejce",
worktree: {
loading: "Ładowanie",
empty: "Folder jest pusty",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/pt-BR/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export default {
version: "Mostrar a versão do Hermes",
},
queuedCancel: "Remover da fila",
queuedEdit: "Editar mensagem na fila",
worktree: {
loading: "Carregando",
empty: "A pasta está vazia",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/pt-PT/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ export default {
queued:
"{{count}} mensagem(ns) em fila — serão enviadas quando o agente terminar",
queuedCancel: "Remover da fila",
queuedEdit: "Editar mensagem na fila",
worktree: {
loading: "A carregar",
empty: "A pasta está vazia",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/tr/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ export default {
},
queued: "{{count}} mesaj sırada — ajan işini bitirince gönderilecek",
queuedCancel: "Sıradan kaldır",
queuedEdit: "Sıradaki mesajı düzenle",
worktree: {
loading: "Yükleniyor",
empty: "Klasör boş",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/zh-CN/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ export default {
version: "查看 Hermes 版本",
},
queuedCancel: "从队列中移除",
queuedEdit: "编辑队列消息",
worktree: {
loading: "加载中",
empty: "文件夹为空",
Expand Down
1 change: 1 addition & 0 deletions src/shared/i18n/locales/zh-TW/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ export default {
},
queued: "{{count}} 則訊息已排隊 — 代理完成後將自動傳送",
queuedCancel: "從佇列中移除",
queuedEdit: "編輯佇列訊息",
worktree: {
loading: "載入中",
empty: "資料夾是空的",
Expand Down
Loading