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
20 changes: 20 additions & 0 deletions src/renderer/src/assets/main.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/renderer/src/screens/Chat/Chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ function Chat({
useChatIPC({
runId,
sessionScopeId: visibleSessionScopeId,
enabled: !dashboardChatEnabled,
setMessages,
setHermesSessionId,
setToolProgress,
Expand Down Expand Up @@ -532,6 +533,7 @@ function Chat({
used: usage.contextTokens,
window:
realContextWindow ?? contextWindowForModel(modelConfig.currentModel),
promptTokens: usage.promptTokens,
cacheReadTokens: usage.cacheReadTokens,
cacheWriteTokens: usage.cacheWriteTokens,
}
Expand Down
50 changes: 45 additions & 5 deletions src/renderer/src/screens/Chat/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
const [slashSelectedIndex, setSlashSelectedIndex] = useState(0);
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [attachmentError, setAttachmentError] = useState<string | null>(null);
const [dragIndex, setDragIndex] = useState<number | null>(null);
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const slashMenuRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
Expand Down Expand Up @@ -382,6 +384,35 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
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;
});
}
Comment on lines +400 to +409

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 Drag state not cleared on successful drop

handleDrop reorders the attachments but leaves dragIndex and dragOverIndex set. The onDragEnd event does fire after onDrop per the HTML5 DnD spec and will clear them, but calling handleDragEnd() directly inside handleDrop makes the cleanup unconditional and symmetrical, and avoids any edge-case flash if onDragEnd is delayed or skipped in unusual browser environments.

Suggested change
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 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;
});
handleDragEnd();
}

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


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
Expand Down Expand Up @@ -455,12 +486,21 @@ export const ChatInput = forwardRef<ChatInputHandle, ChatInputProps>(
)}
{(attachments.length > 0 || attachmentError) && (
<div className="chat-attachment-strip">
{attachments.map((att) => (
<AttachmentChip
{attachments.map((att, i) => (
<div
key={att.id}
attachment={att}
onRemove={() => removeAttachment(att.id)}
/>
className={`attachment-drag-wrapper${dragOverIndex === i ? " attachment-drag-over" : ""}${dragIndex === i ? " attachment-dragging" : ""}`}

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 Drop-target outline shown on the item being dragged

When dragOverIndex === dragIndex === i (the cursor re-enters the source element during a drag), the wrapper receives both attachment-dragging (faded) and attachment-drag-over (dashed outline) at the same time. The outline only makes sense for valid other drop targets; guarding the condition with dragIndex !== i prevents the visual conflict.

Suggested change
className={`attachment-drag-wrapper${dragOverIndex === i ? " attachment-drag-over" : ""}${dragIndex === i ? " attachment-dragging" : ""}`}
className={`attachment-drag-wrapper${dragOverIndex === i && dragIndex !== 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}
Comment on lines +495 to +497

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 Dashed outline lingers when cursor leaves the strip

dragOverIndex is only updated when the cursor enters a new wrapper element, so once the cursor exits all attachment wrappers (e.g., moves into the textarea) the outline stays painted on the last-hovered chip for the remainder of the drag. Adding onDragLeave to reset dragOverIndex to null clears it immediately.

Suggested change
onDragOver={(e) => handleDragOver(e, i)}
onDrop={(e) => handleDrop(e, i)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, i)}
onDragLeave={() => setDragOverIndex(null)}
onDrop={(e) => handleDrop(e, i)}
onDragEnd={handleDragEnd}

>
<AttachmentChip
attachment={att}
onRemove={() => removeAttachment(att.id)}
/>
</div>
))}
{attachmentError && (
<div className="chat-attachment-error" role="alert">
Expand Down
10 changes: 8 additions & 2 deletions src/renderer/src/screens/Chat/ContextGauge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
used: number;
/** Model context window in tokens. */
window: number;
/** Cumulative prompt tokens across all turns in this session. */
promptTokens?: number;
cacheReadTokens?: number;
cacheWriteTokens?: number;
}
Expand All @@ -31,6 +33,7 @@
export const ContextGauge = memo(function ContextGauge({
used,
window: ctxWindow,
promptTokens,
cacheReadTokens,
cacheWriteTokens,
}: ContextUsage): React.JSX.Element {
Expand All @@ -48,9 +51,12 @@

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);

Check warning on line 56 in src/renderer/src/screens/Chat/ContextGauge.tsx

View workflow job for this annotation

GitHub Actions / check

Replace `(used·||·1)` with `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 (
Expand Down
7 changes: 6 additions & 1 deletion src/renderer/src/screens/Chat/hooks/useChatIPC.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<React.SetStateAction<ChatMessage[]>>;
setHermesSessionId: (id: string) => void;
setToolProgress: (tool: string | null) => void;
Expand Down Expand Up @@ -45,6 +47,7 @@ export function eventMatchesRun(eventRunId: string, ownRunId: string): boolean {
export function useChatIPC({
runId,
sessionScopeId,
enabled = true,
setMessages,
setHermesSessionId,
setToolProgress,
Expand Down Expand Up @@ -73,6 +76,7 @@ export function useChatIPC({
}, [sessionScopeId, stopDbPolling]);

useEffect(() => {
if (!enabled) return;
let disposed = false;

const refreshFromDb = async (sessionId: string): Promise<void> => {
Expand Down Expand Up @@ -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,
}));
Expand All @@ -317,6 +321,7 @@ export function useChatIPC({
cleanupUsage();
};
}, [
enabled,
runId,
setMessages,
setHermesSessionId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -593,7 +593,7 @@ function usageFromPayload(payload: unknown): Partial<UsageState> | null {
promptTokens,
completionTokens,
totalTokens,
contextTokens: promptTokens || undefined,
contextTokens: promptTokens,
};
}

Expand Down Expand Up @@ -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,
}));
Expand Down
8 changes: 7 additions & 1 deletion src/renderer/src/screens/Layout/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Comment on lines +440 to 448

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 Race-condition dedup switches to the wrong run ID

When the setRuns functional updater finds an existing run for the session, it correctly discards the newly-minted run — but setActiveRunId(run.runId) is still called with the discarded run's ID, which was never inserted into state. Any downstream lookup of activeRunId will resolve to undefined, leaving the user on a blank or broken chat tab. The dedup comment says "switch to it instead", so existing.runId is the intended target.

The fix requires capturing existing outside the functional updater. One approach: run the dedup check before calling setRuns, read existing from the current snapshot, and use it to decide which ID to activate. Note: runs is captured in the useCallback dependency array, so it is current at the time handleResumeSession fires.

} finally {
Expand Down
Loading