Skip to content
Merged
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
30 changes: 28 additions & 2 deletions apps/desktop/src/app/chat/composer/inline-refs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,42 @@ import { composerPlainText, escapeHtml, placeCaretEnd, refChipHtml } from './ric
/** A chip to insert: a raw `@kind:value` string, or a typed value + display label. */
export type InlineRefInput = string | { kind: string; label?: string; value: string }

/** MIME for an in-app session drag (sidebar row → composer). */
/** MIME for an in-app session drag (sidebar row → composer / pin sections). */
export const HERMES_SESSION_MIME = 'application/x-hermes-session'

/** Marker MIME set when the dragged row is currently pinned. dragover events
* expose only `types` (payload data is sealed until drop), so pin-state has to
* ride as a type for drop zones to filter drags they would act on. */
export const HERMES_SESSION_PINNED_MIME = 'application/x-hermes-session-pinned'

export interface SessionDragPayload {
id: string
profile: string
title: string
/** Durable pin id (lineage root) — what pin/unpin drop targets key on. */
pinId?: string
/** True when the drag started on a pinned sidebar row. */
pinned?: boolean
}

export function writeSessionDrag(transfer: DataTransfer, payload: SessionDragPayload) {
transfer.setData(HERMES_SESSION_MIME, JSON.stringify(payload))

if (payload.pinned) {
transfer.setData(HERMES_SESSION_PINNED_MIME, '1')
}

transfer.effectAllowed = 'copy'
}

export function dragHasSession(transfer: DataTransfer | null) {
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_MIME)
}

export function dragSessionIsPinned(transfer: DataTransfer | null) {
return Boolean(transfer) && Array.from(transfer!.types || []).includes(HERMES_SESSION_PINNED_MIME)
}

export function readSessionDrag(transfer: DataTransfer | null): null | SessionDragPayload {
const raw = transfer?.getData(HERMES_SESSION_MIME)

Expand All @@ -36,7 +54,15 @@ export function readSessionDrag(transfer: DataTransfer | null): null | SessionDr
try {
const parsed = JSON.parse(raw) as Partial<SessionDragPayload>

return parsed.id ? { id: parsed.id, profile: parsed.profile || 'default', title: parsed.title || '' } : null
return parsed.id
? {
id: parsed.id,
pinId: parsed.pinId || parsed.id,
pinned: Boolean(parsed.pinned),
profile: parsed.profile || 'default',
title: parsed.title || ''
}
: null
} catch {
return null
}
Expand Down
46 changes: 43 additions & 3 deletions apps/desktop/src/app/chat/sidebar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,10 @@ import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'

import { SidebarCronJobsSection } from './cron-jobs-section'
import { SidebarRemoteSessionsSection } from './remote-sessions-section'
import { ProfileRail } from './profile-switcher'
import { SidebarRemoteSessionsSection } from './remote-sessions-section'
import { SidebarSessionRow } from './session-row'
import { useSessionDropZone } from './use-session-drop-zone'
import { VirtualSessionList } from './virtual-session-list'

const VIRTUALIZE_THRESHOLD = 25
Expand Down Expand Up @@ -387,6 +388,27 @@ export function ChatSidebar({
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
)

// Row bodies are native-draggable (the same drag that drops a session into
// the composer), so Pinned/Sessions accept that drag directly: drop a row on
// Pinned to pin it, drop a pinned row on Sessions to unpin — no context menu
// round-trip. Auto-open the target section so the landed row is visible even
// when it was collapsed.
const pinnedDropZone = useSessionDropZone({
acceptPinned: false,
onDropSession: payload => {
pinSession(payload.pinId ?? payload.id)
setSidebarPinsOpen(true)
}
})

const sessionsDropZone = useSessionDropZone({
acceptPinned: true,
onDropSession: payload => {
unpinSession(payload.pinId ?? payload.id)
setSidebarRecentsOpen(true)
}
})

// Profile scope = the "workspace switcher" context. Concrete scope shows only
// that profile's sessions (clean rows, no per-row tags); ALL fans every
// profile in, grouped by profile below. Single-profile users land here with
Expand Down Expand Up @@ -873,6 +895,8 @@ export function ChatSidebar({
activeSessionId={activeSidebarSessionId}
contentClassName="flex min-h-10 shrink-0 flex-col gap-px rounded-lg pb-2 pt-1"
dndSensors={dndSensors}
dropActive={pinnedDropZone.active}
dropHandlers={pinnedDropZone.dropHandlers}
emptyState={<SidebarPinnedEmptyState />}
label={s.pinned}
onArchiveSession={onArchiveSession}
Expand Down Expand Up @@ -900,6 +924,8 @@ export function ChatSidebar({
showAllProfiles ? 'gap-3' : 'gap-px'
)}
dndSensors={dndSensors}
dropActive={sessionsDropZone.active}
dropHandlers={sessionsDropZone.dropHandlers}
emptyState={showSessionSkeletons ? <SidebarSessionSkeletons /> : <SidebarAllPinnedState />}
footer={
// Hide "load more" only when workspace-grouped (those groups page
Expand Down Expand Up @@ -1222,6 +1248,10 @@ interface SidebarSessionsSectionProps {
sortable?: boolean
onReorder?: (event: DragEndEvent) => void
dndSensors?: ReturnType<typeof useSensors>
/** Native session-drag drop target (drag-to-pin/unpin): true while an
* acceptable row drag hovers the section. */
dropActive?: boolean
dropHandlers?: ReturnType<typeof useSessionDropZone>['dropHandlers']
}

function SidebarSessionsSection({
Expand All @@ -1248,7 +1278,9 @@ function SidebarSessionsSection({
labelIcon,
sortable = false,
onReorder,
dndSensors
dndSensors,
dropActive = false,
dropHandlers
}: SidebarSessionsSectionProps) {
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
const showEmptyState = forceEmptyState || (!hasGroupedSessions && sessions.length === 0)
Expand Down Expand Up @@ -1363,7 +1395,15 @@ function SidebarSessionsSection({
const resolvedContentClassName = cn(contentClassName, flatVirtualized && 'overflow-y-visible')

return (
<SidebarGroup className={rootClassName}>
<SidebarGroup
className={cn(
rootClassName,
// Light the whole section (header included — drops there count too, even
// collapsed) while an acceptable row drag hovers it.
dropActive && 'rounded-lg bg-(--ui-control-hover-background) ring-1 ring-inset ring-(--ui-stroke-tertiary)'
)}
{...dropHandlers}
>
<SidebarSectionHeader
action={headerAction}
icon={labelIcon}
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/app/chat/sidebar/session-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
import { $attentionSessionIds, sessionPinId } from '@/store/session'
import type { SessionPresenceRecord } from '@/types/hermes'

import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
Expand Down Expand Up @@ -107,6 +107,8 @@ export function SidebarSessionRow({

writeSessionDrag(event.dataTransfer, {
id: session.id,
pinId: sessionPinId(session),
pinned: isPinned,
profile: session.profile || 'default',
title
})
Expand Down
196 changes: 196 additions & 0 deletions apps/desktop/src/app/chat/sidebar/use-session-drop-zone.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'

import {
HERMES_SESSION_MIME,
HERMES_SESSION_PINNED_MIME,
readSessionDrag,
type SessionDragPayload,
writeSessionDrag
} from '@/app/chat/composer/inline-refs'

import { useSessionDropZone } from './use-session-drop-zone'

// jsdom has no DataTransfer; a plain object with the same surface is enough
// for both the writer (setData/effectAllowed) and the zone (types/getData).
function fakeTransfer(data: Record<string, string> = {}) {
const store = { ...data }

return {
dropEffect: 'none',
effectAllowed: 'uninitialized',
getData: (type: string) => store[type] ?? '',
setData: (type: string, value: string) => {
store[type] = value
},
get types() {
return Object.keys(store)
}
} as unknown as DataTransfer
}

function sessionTransfer(payload: SessionDragPayload) {
const transfer = fakeTransfer()
writeSessionDrag(transfer, payload)

return transfer
}

const UNPINNED_ROW: SessionDragPayload = {
id: 'live-1',
pinId: 'root-1',
pinned: false,
profile: 'default',
title: 'Recent session'
}

const PINNED_ROW: SessionDragPayload = {
id: 'live-2',
pinId: 'root-2',
pinned: true,
profile: 'default',
title: 'Pinned session'
}

function Probe({
acceptPinned,
onDropSession
}: {
acceptPinned: boolean
onDropSession: (session: SessionDragPayload) => void
}) {
const { active, dropHandlers } = useSessionDropZone({ acceptPinned, onDropSession })

return (
<div data-active={active ? 'true' : 'false'} data-testid="zone" {...dropHandlers}>
<span data-testid="zone-child">rows</span>
</div>
)
}

afterEach(cleanup)

describe('writeSessionDrag / readSessionDrag pin metadata', () => {
it('round-trips pinId and pinned, flagging pinned drags as a readable type', () => {
const transfer = sessionTransfer(PINNED_ROW)

expect(Array.from(transfer.types)).toContain(HERMES_SESSION_MIME)
expect(Array.from(transfer.types)).toContain(HERMES_SESSION_PINNED_MIME)
expect(readSessionDrag(transfer)).toEqual(PINNED_ROW)
})

it('omits the pinned marker for unpinned rows', () => {
const transfer = sessionTransfer(UNPINNED_ROW)

expect(Array.from(transfer.types)).not.toContain(HERMES_SESSION_PINNED_MIME)
expect(readSessionDrag(transfer)).toEqual(UNPINNED_ROW)
})

it('falls back to the live id as pinId for payloads written without one', () => {
const transfer = fakeTransfer({
[HERMES_SESSION_MIME]: JSON.stringify({ id: 'legacy-1', profile: 'default', title: 'Old payload' })
})

expect(readSessionDrag(transfer)).toEqual({
id: 'legacy-1',
pinId: 'legacy-1',
pinned: false,
profile: 'default',
title: 'Old payload'
})
})
})

describe('useSessionDropZone', () => {
it('accepts an unpinned row drag when acceptPinned=false and drops it', () => {
const onDropSession = vi.fn()
render(<Probe acceptPinned={false} onDropSession={onDropSession} />)
const zone = screen.getByTestId('zone')
const transfer = sessionTransfer(UNPINNED_ROW)

fireEvent.dragEnter(zone, { dataTransfer: transfer })
expect(zone.dataset.active).toBe('true')

// preventDefault on dragover is what makes the browser allow the drop.
expect(fireEvent.dragOver(zone, { dataTransfer: transfer })).toBe(false)

fireEvent.drop(zone, { dataTransfer: transfer })
expect(onDropSession).toHaveBeenCalledWith(UNPINNED_ROW)
expect(zone.dataset.active).toBe('false')
})

it('accepts a pinned row drag when acceptPinned=true and drops it', () => {
const onDropSession = vi.fn()
render(<Probe acceptPinned onDropSession={onDropSession} />)
const zone = screen.getByTestId('zone')
const transfer = sessionTransfer(PINNED_ROW)

fireEvent.dragEnter(zone, { dataTransfer: transfer })
expect(zone.dataset.active).toBe('true')

fireEvent.drop(zone, { dataTransfer: transfer })
expect(onDropSession).toHaveBeenCalledWith(PINNED_ROW)
})

it('ignores drags whose pin-state it would not act on', () => {
const onDropSession = vi.fn()
render(<Probe acceptPinned={false} onDropSession={onDropSession} />)
const zone = screen.getByTestId('zone')
const transfer = sessionTransfer(PINNED_ROW)

fireEvent.dragEnter(zone, { dataTransfer: transfer })
expect(zone.dataset.active).toBe('false')

// No preventDefault → the drop stays disallowed for this zone.
expect(fireEvent.dragOver(zone, { dataTransfer: transfer })).toBe(true)

fireEvent.drop(zone, { dataTransfer: transfer })
expect(onDropSession).not.toHaveBeenCalled()
})

it('ignores non-session drags entirely', () => {
const onDropSession = vi.fn()
render(<Probe acceptPinned={false} onDropSession={onDropSession} />)
const zone = screen.getByTestId('zone')
const transfer = fakeTransfer({ 'text/plain': 'not a session' })

fireEvent.dragEnter(zone, { dataTransfer: transfer })
expect(zone.dataset.active).toBe('false')

fireEvent.drop(zone, { dataTransfer: transfer })
expect(onDropSession).not.toHaveBeenCalled()
})

it('keeps the highlight while moving across nested children', () => {
render(<Probe acceptPinned={false} onDropSession={vi.fn()} />)
const zone = screen.getByTestId('zone')
const child = screen.getByTestId('zone-child')
const transfer = sessionTransfer(UNPINNED_ROW)

fireEvent.dragEnter(zone, { dataTransfer: transfer })
fireEvent.dragEnter(child, { dataTransfer: transfer })
fireEvent.dragLeave(zone, { dataTransfer: transfer })
expect(zone.dataset.active).toBe('true')

fireEvent.dragLeave(child, { dataTransfer: transfer })
expect(zone.dataset.active).toBe('false')
})

it('does not wedge after stray leave events from unaccepted drags', () => {
render(<Probe acceptPinned={false} onDropSession={vi.fn()} />)
const zone = screen.getByTestId('zone')
const pinned = sessionTransfer(PINNED_ROW)

// A drag this zone ignores still emits leave events on the way out.
fireEvent.dragEnter(zone, { dataTransfer: pinned })
fireEvent.dragLeave(zone, { dataTransfer: pinned })
fireEvent.dragLeave(zone, { dataTransfer: pinned })

const accepted = sessionTransfer(UNPINNED_ROW)
fireEvent.dragEnter(zone, { dataTransfer: accepted })
expect(zone.dataset.active).toBe('true')

fireEvent.dragLeave(zone, { dataTransfer: accepted })
expect(zone.dataset.active).toBe('false')
})
})
Loading
Loading