From 787fc5bd21d1261e33360e5adf4a1541233133b4 Mon Sep 17 00:00:00 2001 From: Hank Zhang Date: Wed, 17 Jun 2026 12:17:41 +0800 Subject: [PATCH 1/5] feat: add drag-and-drop reordering for attachments Attachments in the composer strip can now be reordered by dragging them to a new position. Visual feedback: dragged item fades to 40% opacity, drop target shows a dashed outline. Changes: - ChatInput: dragIndex/dragOverIndex state, dragStart/dragOver/ drop/dragEnd handlers, attachment reorder via array splice - main.css: .attachment-drag-wrapper (grab cursor), .attachment- dragging (faded), .attachment-drag-over (dashed outline) --- src/renderer/src/assets/main.css | 20 +++++++++ src/renderer/src/screens/Chat/ChatInput.tsx | 50 ++++++++++++++++++--- 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index a1585ad6a..1b8391260 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -3925,6 +3925,26 @@ body { color: var(--text-secondary); } +/* Drag-and-drop attachment reorder wrapper */ +.attachment-drag-wrapper { + cursor: grab; + transition: opacity 0.15s ease; +} + +.attachment-drag-wrapper:active { + cursor: grabbing; +} + +.attachment-dragging { + opacity: 0.4; +} + +.attachment-drag-over { + outline: 2px dashed var(--border-focus, var(--accent)); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + .attachment-chip { position: relative; display: inline-flex; diff --git a/src/renderer/src/screens/Chat/ChatInput.tsx b/src/renderer/src/screens/Chat/ChatInput.tsx index 9efa20a2b..62836de14 100644 --- a/src/renderer/src/screens/Chat/ChatInput.tsx +++ b/src/renderer/src/screens/Chat/ChatInput.tsx @@ -82,6 +82,8 @@ export const ChatInput = forwardRef( const [slashSelectedIndex, setSlashSelectedIndex] = useState(0); const [attachments, setAttachments] = useState([]); const [attachmentError, setAttachmentError] = useState(null); + const [dragIndex, setDragIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); const inputRef = useRef(null); const slashMenuRef = useRef(null); const fileInputRef = useRef(null); @@ -382,6 +384,35 @@ export const ChatInput = forwardRef( setAttachmentError(null); } + // Drag-and-drop reordering + function handleDragStart(e: React.DragEvent, index: number): void { + setDragIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(index)); + } + + function handleDragOver(e: React.DragEvent, index: number): void { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + setDragOverIndex(index); + } + + function handleDrop(e: React.DragEvent, index: number): void { + e.preventDefault(); + if (dragIndex === null || dragIndex === index) return; + setAttachments((prev) => { + const next = [...prev]; + const [moved] = next.splice(dragIndex, 1); + next.splice(index, 0, moved); + return next; + }); + } + + function handleDragEnd(): void { + setDragIndex(null); + setDragOverIndex(null); + } + // Pre-send validation gate (#369): even with the queue model from // PR #379, we still block Send when readiness fails — a queued message // with a missing API key would just fail later. The !isLoading gate @@ -455,12 +486,21 @@ export const ChatInput = forwardRef( )} {(attachments.length > 0 || attachmentError) && (
- {attachments.map((att) => ( - ( +
removeAttachment(att.id)} - /> + className={`attachment-drag-wrapper${dragOverIndex === i ? " attachment-drag-over" : ""}${dragIndex === i ? " attachment-dragging" : ""}`} + draggable + onDragStart={(e) => handleDragStart(e, i)} + onDragOver={(e) => handleDragOver(e, i)} + onDrop={(e) => handleDrop(e, i)} + onDragEnd={handleDragEnd} + > + removeAttachment(att.id)} + /> +
))} {attachmentError && (
From 750ba4d80a2c9c11fa5ae4409fd4cea83a3c4804 Mon Sep 17 00:00:00 2001 From: hankkyy <> Date: Thu, 18 Jun 2026 14:35:23 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix:=20context=20gauge=20stuck=20=E2=80=94?= =?UTF-8?q?=20||=E2=86=92=3F=3F=20for=20contextTokens=20to=20not=20treat?= =?UTF-8?q?=200=20as=20falsy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: contextTokens used || (logical OR) which treated 0 as falsy. When backend sent promptTokens=0 (or stopped sending), the gauge kept the stale previous value and never updated. Root Cause: The || operator falls through to previous value when the new value is 0, null, or undefined. But 0 is a valid value (empty context after session switch). Fix: Use ?? (nullish coalescing) so only null/undefined fall through. Also removed || undefined wrapper in usageFromPayload. Files: - useChatIPC.ts L300: || → ?? - useDashboardChatTransport.ts L886: || → ?? - useDashboardChatTransport.ts L596: removed || undefined --- src/renderer/src/screens/Chat/hooks/useChatIPC.ts | 2 +- .../src/screens/Chat/hooks/useDashboardChatTransport.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/screens/Chat/hooks/useChatIPC.ts b/src/renderer/src/screens/Chat/hooks/useChatIPC.ts index 2e349603a..55f2f45c5 100644 --- a/src/renderer/src/screens/Chat/hooks/useChatIPC.ts +++ b/src/renderer/src/screens/Chat/hooks/useChatIPC.ts @@ -297,7 +297,7 @@ export function useChatIPC({ completionTokens: (prev?.completionTokens || 0) + u.completionTokens, totalTokens: (prev?.totalTokens || 0) + u.totalTokens, cost: u.cost != null ? (prev?.cost || 0) + u.cost : prev?.cost, - contextTokens: u.promptTokens || prev?.contextTokens, + contextTokens: u.promptTokens ?? prev?.contextTokens, cacheReadTokens: u.cacheReadTokens ?? prev?.cacheReadTokens, cacheWriteTokens: u.cacheWriteTokens ?? prev?.cacheWriteTokens, })); diff --git a/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts b/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts index 7e3bd3265..f921de91c 100644 --- a/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts +++ b/src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts @@ -593,7 +593,7 @@ function usageFromPayload(payload: unknown): Partial | null { promptTokens, completionTokens, totalTokens, - contextTokens: promptTokens || undefined, + contextTokens: promptTokens, }; } @@ -883,7 +883,7 @@ export function useDashboardChatTransport({ (prev?.completionTokens || 0) + (usage.completionTokens || 0), totalTokens: (prev?.totalTokens || 0) + (usage.totalTokens || 0), cost: prev?.cost, - contextTokens: usage.contextTokens || prev?.contextTokens, + contextTokens: usage.contextTokens ?? prev?.contextTokens, cacheReadTokens: prev?.cacheReadTokens, cacheWriteTokens: prev?.cacheWriteTokens, })); From 95689d22b75da29834060e390097fdb1822cc707 Mon Sep 17 00:00:00 2001 From: hankkyy <> Date: Thu, 18 Jun 2026 14:37:48 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20context=20gauge=20cache=20%=20?= =?UTF-8?q?=E2=80=94=20use=20cumulative=20promptTokens,=20not=20single-tur?= =?UTF-8?q?n=20contextTokens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: cacheHitPct = cacheReadTokens / used where 'used' is the latest turn's prompt tokens. Cache tokens can span multiple turns, so 307k cache / 14k used = 2196% → clamped to 100% — nonsensical '100% hit' display. Fix: pass cumulative promptTokens from Chat.tsx to ContextGauge. Cache % = cacheReadTokens / promptTokens (session total). Falls back to 'used' when promptTokens is unavailable. --- src/renderer/src/screens/Chat/Chat.tsx | 1 + src/renderer/src/screens/Chat/ContextGauge.tsx | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index eed0a00b4..dbbd9b3ac 100644 --- a/src/renderer/src/screens/Chat/Chat.tsx +++ b/src/renderer/src/screens/Chat/Chat.tsx @@ -532,6 +532,7 @@ function Chat({ used: usage.contextTokens, window: realContextWindow ?? contextWindowForModel(modelConfig.currentModel), + promptTokens: usage.promptTokens, cacheReadTokens: usage.cacheReadTokens, cacheWriteTokens: usage.cacheWriteTokens, } diff --git a/src/renderer/src/screens/Chat/ContextGauge.tsx b/src/renderer/src/screens/Chat/ContextGauge.tsx index 24c9007ef..04fe1f280 100644 --- a/src/renderer/src/screens/Chat/ContextGauge.tsx +++ b/src/renderer/src/screens/Chat/ContextGauge.tsx @@ -6,6 +6,8 @@ export interface ContextUsage { used: number; /** Model context window in tokens. */ window: number; + /** Cumulative prompt tokens across all turns in this session. */ + promptTokens?: number; cacheReadTokens?: number; cacheWriteTokens?: number; } @@ -31,6 +33,7 @@ function fmtTokens(n: number): string { export const ContextGauge = memo(function ContextGauge({ used, window: ctxWindow, + promptTokens, cacheReadTokens, cacheWriteTokens, }: ContextUsage): React.JSX.Element { @@ -48,9 +51,12 @@ export const ContextGauge = memo(function ContextGauge({ const hasCache = cacheReadTokens !== undefined || cacheWriteTokens !== undefined; + // Use cumulative promptTokens if available — cache hits are a fraction + // of total prompt tokens across the session, not just the latest turn. + const cacheBase = promptTokens && promptTokens > 0 ? promptTokens : (used || 1); const cacheHitPct = - used > 0 && cacheReadTokens - ? Math.min(100, Math.round((cacheReadTokens / used) * 100)) + cacheBase > 0 && cacheReadTokens + ? Math.min(100, Math.round((cacheReadTokens / cacheBase) * 100)) : 0; return ( From 03b9dc71fd944948ddc87e2214491e5ca1b83d77 Mon Sep 17 00:00:00 2001 From: hankkyy <> Date: Thu, 18 Jun 2026 14:46:15 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix:=20prevent=20duplicate=20session=20tabs?= =?UTF-8?q?=20=E2=80=94=20check=20existing=20in=20setRuns=20updater?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a session is opened twice before the first fetch completes, both async handlers could create separate runs for the same sessionId, resulting in two tabs showing identical content that appears 'mixed'. The resumingRef guard only covers concurrent async calls, but a second click after the first fetch lands but before React commits the state update could still create a duplicate. The setRuns functional updater now checks for an existing run with the same sessionId. --- src/renderer/src/screens/Layout/Layout.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/screens/Layout/Layout.tsx b/src/renderer/src/screens/Layout/Layout.tsx index 05c4f5579..1d1ac4b0b 100644 --- a/src/renderer/src/screens/Layout/Layout.tsx +++ b/src/renderer/src/screens/Layout/Layout.tsx @@ -437,7 +437,13 @@ function Layout({ )) as DbHistoryItem[]; const run = mintRun(activeProfile, dbItemsToChatMessages(items)); run.sessionId = sessionId; - setRuns((prev) => [...prev, run]); + setRuns((prev) => { + // Defend against duplicate opens: if another run for this session + // already landed while we were fetching, switch to it instead. + const existing = prev.find((r) => r.sessionId === sessionId); + if (existing) return prev; + return [...prev, run]; + }); setActiveRunId(run.runId); goTo("chat"); } finally { From 2fbb57f60efc8984e1feaf6b48e6c2c6429e6d11 Mon Sep 17 00:00:00 2001 From: hankkyy <> Date: Thu, 18 Jun 2026 14:52:22 +0800 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20session=20mixing=20=E2=80=94=20disab?= =?UTF-8?q?le=20legacy=20useChatIPC=20when=20dashboard=20transport=20activ?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause: Both useChatIPC and useDashboardChatTransport hooks register IPC event listeners and DB polling simultaneously. When two agent sessions run in parallel, both hooks process events and call setMessages — the duelling state updates can cause messages from one session to leak into another via reconciliation races. Fix: Add enabled flag to useChatIPC, set to false when dashboard transport is handling the chat (dashboardChatEnabled=true). This eliminates the dual-listener conflict while preserving legacy IPC for non-dashboard mode. --- src/renderer/src/screens/Chat/Chat.tsx | 1 + src/renderer/src/screens/Chat/hooks/useChatIPC.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/renderer/src/screens/Chat/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index dbbd9b3ac..fb942e5ee 100644 --- a/src/renderer/src/screens/Chat/Chat.tsx +++ b/src/renderer/src/screens/Chat/Chat.tsx @@ -257,6 +257,7 @@ function Chat({ useChatIPC({ runId, sessionScopeId: visibleSessionScopeId, + enabled: !dashboardChatEnabled, setMessages, setHermesSessionId, setToolProgress, diff --git a/src/renderer/src/screens/Chat/hooks/useChatIPC.ts b/src/renderer/src/screens/Chat/hooks/useChatIPC.ts index 55f2f45c5..4681e7a73 100644 --- a/src/renderer/src/screens/Chat/hooks/useChatIPC.ts +++ b/src/renderer/src/screens/Chat/hooks/useChatIPC.ts @@ -18,6 +18,8 @@ interface UseChatIPCArgs { runId: string; /** The session currently visible in this Chat, if already known. */ sessionScopeId: string | null; + /** When false, skip all event registrations (dashboard transport handles them). */ + enabled?: boolean; setMessages: React.Dispatch>; setHermesSessionId: (id: string) => void; setToolProgress: (tool: string | null) => void; @@ -45,6 +47,7 @@ export function eventMatchesRun(eventRunId: string, ownRunId: string): boolean { export function useChatIPC({ runId, sessionScopeId, + enabled = true, setMessages, setHermesSessionId, setToolProgress, @@ -73,6 +76,7 @@ export function useChatIPC({ }, [sessionScopeId, stopDbPolling]); useEffect(() => { + if (!enabled) return; let disposed = false; const refreshFromDb = async (sessionId: string): Promise => { @@ -317,6 +321,7 @@ export function useChatIPC({ cleanupUsage(); }; }, [ + enabled, runId, setMessages, setHermesSessionId,