Skip to content

feat: drag-and-drop attachment reordering#709

Open
hankkyy wants to merge 5 commits into
fathah:mainfrom
hankkyy:feat/drag-attachments
Open

feat: drag-and-drop attachment reordering#709
hankkyy wants to merge 5 commits into
fathah:mainfrom
hankkyy:feat/drag-attachments

Conversation

@hankkyy

@hankkyy hankkyy commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Drag-and-drop reorder attachments in the composer strip. Dragged item fades, drop target shows dashed outline.

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)
@greptile-apps

greptile-apps Bot commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds drag-and-drop reordering to the attachment strip in the chat composer, plus a handful of correctness fixes bundled in: an enabled guard on useChatIPC to avoid double-registering listeners when the dashboard transport is active, ||?? fixes for contextTokens in both transport hooks, a promptTokens prop on ContextGauge for a more accurate cache-hit ratio, and a dedup guard in Layout when resuming sessions concurrently.

  • Drag-and-drop (ChatInput / CSS): wraps each AttachmentChip in a draggable div, tracks dragIndex/dragOverIndex in state, and applies fade + dashed-outline classes for visual feedback.
  • IPC/transport fixes: ?? replaces || so a contextTokens of 0 no longer clobbers a previously stored non-zero value; useChatIPC gains an enabled prop to skip listener setup when the dashboard transport owns the session.
  • Layout race-condition guard: setRuns now checks for an existing run with the same sessionId before adding a duplicate, though the setActiveRunId call after it still uses the discarded run's ID in the dedup path (see inline comment).

Confidence Score: 4/5

Safe to merge with a follow-up fix for the session-resume dedup path in Layout.tsx.

The session-resume dedup guard in Layout.tsx correctly prevents a duplicate run from entering state, but then calls setActiveRunId with the newly minted (and discarded) run's ID. In the race condition the guard is designed to handle, the active run would point at an entry that does not exist in runs, leaving the user on a blank or broken chat tab. All other changes — the DnD feature, IPC guard, and contextTokens fixes — look correct.

src/renderer/src/screens/Layout/Layout.tsx — the setActiveRunId call after the dedup check needs to use the existing run's ID, not the discarded one.

Important Files Changed

Filename Overview
src/renderer/src/screens/Chat/ChatInput.tsx Adds drag-and-drop reorder UI for attachments with dragIndex/dragOverIndex state and four event handlers; previously noted visual edge cases addressed in prior review threads.
src/renderer/src/screens/Layout/Layout.tsx Adds dedup guard inside setRuns functional updater to handle race condition, but setActiveRunId always uses the newly minted run.runId even when the duplicate path fires, leaving the active run pointing at a discarded entry.
src/renderer/src/screens/Chat/hooks/useChatIPC.ts Adds enabled flag to skip IPC listener registration when dashboard transport is active; fixes
src/renderer/src/screens/Chat/ContextGauge.tsx Adds optional promptTokens prop and uses it as the denominator for cache-hit percentage, giving a more accurate ratio across multi-turn sessions.
src/renderer/src/screens/Chat/hooks/useDashboardChatTransport.ts Two
src/renderer/src/assets/main.css Adds CSS for drag-and-drop attachment wrappers: grab cursor, opacity fade for the dragged item, and a dashed outline for the drop target.
src/renderer/src/screens/Chat/Chat.tsx Threads enabled flag to useChatIPC and passes promptTokens to ContextGauge; straightforward wiring changes.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant User
    participant ChatInput
    participant DragState
    participant AttachmentList

    User->>ChatInput: dragstart on chip[i]
    ChatInput->>DragState: setDragIndex(i)
    User->>ChatInput: dragover chip[j]
    ChatInput->>DragState: setDragOverIndex(j)
    Note over ChatInput: chip[j] gets attachment-drag-over class
    User->>ChatInput: drop on chip[j]
    ChatInput->>AttachmentList: splice(i,1) then splice(j,0,moved)
    ChatInput->>DragState: (dragIndex/dragOverIndex remain set)
    User->>ChatInput: dragend fires
    ChatInput->>DragState: setDragIndex(null), setDragOverIndex(null)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant User
    participant ChatInput
    participant DragState
    participant AttachmentList

    User->>ChatInput: dragstart on chip[i]
    ChatInput->>DragState: setDragIndex(i)
    User->>ChatInput: dragover chip[j]
    ChatInput->>DragState: setDragOverIndex(j)
    Note over ChatInput: chip[j] gets attachment-drag-over class
    User->>ChatInput: drop on chip[j]
    ChatInput->>AttachmentList: splice(i,1) then splice(j,0,moved)
    ChatInput->>DragState: (dragIndex/dragOverIndex remain set)
    User->>ChatInput: dragend fires
    ChatInput->>DragState: setDragIndex(null), setDragOverIndex(null)
Loading

Reviews (3): Last reviewed commit: "fix: session mixing — disable legacy use..." | Re-trigger Greptile

Comment on lines +495 to +497
onDragOver={(e) => handleDragOver(e, i)}
onDrop={(e) => handleDrop(e, i)}
onDragEnd={handleDragEnd}

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}

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" : ""}`}

Comment on lines +400 to +409
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;
});
}

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!

hankkyy added 4 commits June 18, 2026 14:35
…falsy

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
…turn contextTokens

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.
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.
…ort active

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.
@hankkyy

hankkyy commented Jun 18, 2026

Copy link
Copy Markdown
Contributor Author

Problem

  1. Context gauge stuck: % and token count never update after first turn.
  2. Cache hit % nonsensical: Shows "100%" (307.5k read / 14k used = 2196% → clamped).
  3. Session mixing: Opening two agent sessions causes messages to leak between tabs.

Root Cause

  1. contextTokens used || which treats 0 as falsy — stale value kept forever.
  2. cacheHitPct = cacheReadTokens / used where used=latest turn but cache tokens are cumulative.
  3. useChatIPC and useDashboardChatTransport both registered IPC listeners/DB polling simultaneously, causing reconciliation races across sessions.

Changes

File Change
useChatIPC.ts L300 `
useDashboardChatTransport.ts L886 Same
useDashboardChatTransport.ts L596 Remove `
ContextGauge.tsx Add promptTokens; calc cache % against cumulative
Chat.tsx L535 Pass usage.promptTokens; enabled: !dashboardChatEnabled
useChatIPC.ts L47,L78 Add enabled param; skip registrations when disabled
Layout.tsx L437 Check for existing sessionId in setRuns updater

Verification

  • npm run typecheck passes
  • npx vitest run passes
  • Context gauge updates each turn; cache % ≤ 100%
  • Two agent sessions open simultaneously — no message leaking when switching tabs

@greptile-apps

greptile-apps Bot commented Jun 18, 2026

Copy link
Copy Markdown
Contributor

Want your agent to iterate on Greptile's feedback? Try greploops.

Comment on lines +440 to 448
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");

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant