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/Chat.tsx b/src/renderer/src/screens/Chat/Chat.tsx index eed0a00b4..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, @@ -532,6 +533,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/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 && (
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 ( diff --git a/src/renderer/src/screens/Chat/hooks/useChatIPC.ts b/src/renderer/src/screens/Chat/hooks/useChatIPC.ts index 2e349603a..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 => { @@ -297,7 +301,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, })); @@ -317,6 +321,7 @@ export function useChatIPC({ cleanupUsage(); }; }, [ + enabled, runId, setMessages, setHermesSessionId, 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, })); 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 {