From 9dcd1843f7fe7742b808242efc9b37d73478ff45 Mon Sep 17 00:00:00 2001 From: Karn Date: Mon, 17 Aug 2026 22:56:29 +0530 Subject: [PATCH 1/2] Drag a row onto a tag heading and it moves there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sessions list grows drag and drop for the one thing a drop can honestly change: tags. A row picked up under one tag heading and let go over another swaps the source tag for the target, dropping on the untagged remainder clears them all, and both travel as the same update the tag editor sends, so dragging is a shortcut for an existing edit rather than a new power. Machine, state and directory headings refuse the drop out loud, with a no-drop cursor in the air and the reason as a notice on release; the ungrouped view offers no drag at all. The verdict is a pure function beside spawnFromGroup, pinned by unit tests; the table wires dnd-kit around it — a mouse pays five pixels to lift, a finger pays a hold so scrolling stays scrolling, and the ghost is a card naming the row rather than the row itself. The keyboard path remains the tag editor, which drag and drop deliberately never replaces. Closes #81. Co-Authored-By: Claude Fable 5 --- web/package.json | 1 + web/pnpm-lock.yaml | 37 ++ web/src/components/session-table.test.tsx | 39 ++ web/src/components/session-table.tsx | 421 +++++++++++++++++----- web/src/routes/sessions.test.tsx | 37 +- web/src/routes/sessions.tsx | 41 ++- web/src/sessions/view.test.ts | 106 ++++++ web/src/sessions/view.ts | 80 ++++ 8 files changed, 654 insertions(+), 108 deletions(-) diff --git a/web/package.json b/web/package.json index 4a7ddd7..3a2f489 100644 --- a/web/package.json +++ b/web/package.json @@ -16,6 +16,7 @@ "lint": "tsc --noEmit" }, "dependencies": { + "@dnd-kit/core": "^6.3.1", "@heroicons/react": "^2.2.0", "@noble/ciphers": "^2.2.0", "@noble/curves": "^2.2.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index e15d366..05709e5 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@heroicons/react': specifier: ^2.2.0 version: 2.2.0(react@19.2.8) @@ -307,6 +310,22 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@dotenvx/dotenvx@1.75.1': resolution: {integrity: sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ==} hasBin: true @@ -3463,6 +3482,24 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.8) + '@dnd-kit/utilities': 3.2.2(react@19.2.8) + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.8)': + dependencies: + react: 19.2.8 + tslib: 2.8.1 + '@dotenvx/dotenvx@1.75.1': dependencies: '@dotenvx/primitives': 0.8.0 diff --git a/web/src/components/session-table.test.tsx b/web/src/components/session-table.test.tsx index 7790c5b..e8c939c 100644 --- a/web/src/components/session-table.test.tsx +++ b/web/src/components/session-table.test.tsx @@ -536,6 +536,45 @@ describe('SessionTable', () => { }) }) + describe('dragging', () => { + // What a drag *does* is dropOnGroup's, pinned in sessions/view.test.ts; + // jsdom draws no layout, so the gesture itself — sensors, collision, + // release — cannot be honestly simulated here. What this component owes + // and can prove is the wiring around the gesture. + + it('leaves the native anchor drag alone until a drag contract arrives', async () => { + const bare = await renderTable() + expect( + bare.getByRole('link', { name: 'Open zsh in a new tab' }).getAttribute('draggable'), + ).toBeNull() + bare.unmount() + + // With one: the browser's own href-drag would race the sensor for + // every gesture starting on the link's stretched overlay, which is + // most of the row — so it stands down. + await renderTable({ drag: { droppable: () => true, onDrop: vi.fn() } }) + const link = screen.getByRole('link', { name: 'Open zsh in a new tab' }) + expect(link.getAttribute('draggable')).toBe('false') + // And the row is still, above all, the link it always was. + expect(link.getAttribute('href')).toBe('/d/m1/s/a1') + }) + + it('claims the long press only while dragging is on offer', async () => { + // select-none and the callout suppression are what let a finger hold a + // row without iOS answering with selection or the link preview — and + // they cost real behaviour (copying a path), so they must not leak + // into lists that cannot drag. + const bare = await renderTable() + expect(bare.container.querySelector('li')!.className).not.toMatch(/select-none/) + bare.unmount() + + const { container } = await renderTable({ + drag: { droppable: () => true, onDrop: vi.fn() }, + }) + expect(container.querySelector('li')!.className).toMatch(/select-none/) + }) + }) + describe('empty state', () => { it('shows the terminal card when there are no groups at all', async () => { const { container } = await renderTable({ groups: [] }) diff --git a/web/src/components/session-table.tsx b/web/src/components/session-table.tsx index c1f8438..6679cea 100644 --- a/web/src/components/session-table.tsx +++ b/web/src/components/session-table.tsx @@ -1,4 +1,17 @@ -import { Fragment } from 'react' +import { Fragment, useState } from 'react' +import { + DndContext, + DragOverlay, + MouseSensor, + TouchSensor, + useDraggable, + useDroppable, + useSensor, + useSensors, + type DragEndEvent, + type DragOverEvent, + type DragStartEvent, +} from '@dnd-kit/core' import { Link } from '@tanstack/react-router' import { ChevronDownIcon, @@ -26,6 +39,32 @@ import { COLUMN_KEYS, displayName, type ColumnKey, type Group } from '@/sessions /** What a row's ⋯ menu can ask of a session. */ export type RowAction = 'rename' | 'tags' | 'pin' | 'unpin' | 'close' +/** + * How a dragged row is let go, when the caller offers dragging at all. + * + * Both halves belong to the caller for the same reason `spawnLabel` does: this + * component is handed labelled runs of rows and cannot tell a tag's heading + * from a machine's, so what a drop *means* — a retag, a refusal said out loud + * — is decided above it (see `dropOnGroup`). `droppable` is what the drag + * layer draws while the row is in the air: a group it answers true for + * highlights under the pointer, any other answers with a no-drop cursor, and + * the drop itself is reported either way so the caller can say why it + * refused. Omitting the whole object turns dragging off, which is what the + * ungrouped view and every test that is not about dragging do. + */ +export interface DragToGroup { + /** Whether this group's heading can take a drop. */ + droppable(group: Group): boolean + /** A row let go over a group — its band, or its run of rows. */ + onDrop(s: FleetSession, fromKey: string, group: Group): void +} + +/** What rides on a draggable row: the session, and the heading it left. */ +interface DragCargo { + s: FleetSession + fromKey: string +} + /** * How a row asks the daemon what it is doing, for the hover preview. * @@ -164,6 +203,8 @@ const TAG_CAP = 3 */ function SessionRow({ s, + groupKey, + drag, paneCount, shown, selected, @@ -172,6 +213,10 @@ function SessionRow({ peek, }: { s: FleetSession + /** The heading this row renders under — what a drag is picked up from. */ + groupKey: string + /** The drag layer's contract, when the caller offers one. See DragToGroup. */ + drag?: DragToGroup /** Panes folded into this row, when its group has more than one. */ paneCount?: number shown: ColumnKey[] @@ -183,6 +228,20 @@ function SessionRow({ const key = keyOf(s) const name = displayName(s) const ended = s.state === 'exited' + /* + * One draggable per rendered row rather than per session: a session tagged + * twice renders under two headings, and the id has to say which heading the + * drag left, or a retag could not know which tag to take off. The newline + * can appear in neither half, so the pair cannot collide with another + * row's. The listeners land on the whole `li` — the activation distance is + * what keeps a plain click a click, so the checkbox, the ⋯ trigger and the + * link all still answer their own presses. + */ + const { setNodeRef, listeners, isDragging } = useDraggable({ + id: `${groupKey}\n${key}`, + data: { s, fromKey: groupKey } satisfies DragCargo, + disabled: drag === undefined, + }) const link = ( @@ -218,7 +281,21 @@ function SessionRow({ ) return ( -
  • +
  • onToggleSelect(key)} @@ -408,6 +485,7 @@ export function SessionTable({ onAction, onSpawnIn, spawnLabel, + drag, peek, }: { groups: Group[] @@ -432,9 +510,29 @@ export function SessionTable({ * of rows and could not tell a machine's heading from a tag's. */ spawnLabel?(group: Group): string | undefined + /** Drag a row onto a group's heading, when that means anything. */ + drag?: DragToGroup /** How a row asks what it is doing, for the hover preview. See PeekFn. */ peek?: PeekFn }) { + /** + * The ghost's subject, held here because only the drag layer's own events + * know it. Ahead of the empty-state return, as every hook must be. + * + * The sensors are two rather than one pointer sensor, because a mouse and + * a finger disagree about what starting a drag should cost. A mouse pays + * five pixels of travel, which is what keeps a plain click a click on a row + * that is mostly one big link. A finger pays a hold — scrolling is also a + * touch sliding up a row, and a distance rule would grab every scroll — + * and the tolerance is what lets a hold that drifts slightly still lift + * rather than demanding a surgeon's stillness. + */ + const [dragging, setDragging] = useState(null) + const sensors = useSensors( + useSensor(MouseSensor, { activationConstraint: { distance: 5 } }), + useSensor(TouchSensor, { activationConstraint: { delay: 300, tolerance: 8 } }), + ) + if (groups.length === 0) { /* * The empty state quotes the landing page's terminal figure: a dark card @@ -472,100 +570,233 @@ export function SessionTable({ // separating: the single 'all' group of the ungrouped view. const ungrouped = groups.length === 1 && groups[0]!.key === 'all' + /* + * The drag layer's own bookkeeping, all of it about saying things: who the + * ghost shows, what the cursor promises, and the one report a drop makes. + * The cursor rides document.body because during a drag the pointer is + * captured — no element under it is hovered, so no element's cursor rule + * can speak. `no-drop` over a heading the caller refuses is the drop + * rejected *before* it happens; the caller then says why in words when the + * reader insists (see DragToGroup). + */ + const settle = () => { + setDragging(null) + document.body.style.cursor = '' + } + const dragStart = (e: DragStartEvent) => { + setDragging((e.active.data.current as DragCargo).s) + document.body.style.cursor = 'grabbing' + } + const dragOver = (e: DragOverEvent) => { + if (drag === undefined) return + const group = (e.over?.data.current as { group: Group } | undefined)?.group + document.body.style.cursor = + group !== undefined && !drag.droppable(group) ? 'no-drop' : 'grabbing' + } + const dragEnd = (e: DragEndEvent) => { + settle() + const group = (e.over?.data.current as { group: Group } | undefined)?.group + if (drag === undefined || group === undefined) return + const cargo = e.active.data.current as DragCargo + drag.onDrop(cargo.s, cargo.fromKey, group) + } + return ( -
    - {groups.map((g) => { - const open = !collapsed.has(g.key) - // Where the pinned prefix ends; 0 and -1 both mean "no rule". - const boundary = ungrouped ? g.sessions.findIndex((s) => !s.pinned) : 0 - /* - * What this heading's `+` would be called, and therefore whether it - * exists at all. - * - * Resolved once, per group, and checked — not merely passed to - * aria-label. A group that refuses one answers undefined (see - * `spawnFromGroup`, and "Exited" for the case that motivates it), and - * an unchecked answer rendered a button with no accessible name: a - * `+` a pointer can press, a click the caller then refuses, and - * nothing for a screen reader to announce it as. - */ - const spawn = onSpawnIn === undefined ? undefined : spawnLabel?.(g) - return ( -
    - {/* - Two real controls in the band, and they are kept apart on - purpose: folding a group and starting a session in it must never - be the same click. The toggle owns the chevron and the label so - its accessible name is the group's; the tally is read-along - text; and the `+` is ranged right, where a new-thing control - belongs and where a pointer aimed at the fold cannot reach it. - It appears only when the caller can honour it — see - `spawnFromGroup`, which is what decides that a heading like - "Exited" has nothing to offer. - */} -
    - - - {tally(g.sessions)} - - {spawn !== undefined && onSpawnIn !== undefined && ( - - )} -
    - {open && ( -
      - {g.sessions.map((s, at) => ( - // Keyed by the same composite the selection uses: unique - // within a group, even when the session itself repeats - // across tag groups in their own lists. - - {at === boundary && at > 0 && } - - - ))} -
    + +
    + {groups.map((g) => ( + !s.pinned) : 0} + /* + * What this heading's `+` would be called, and therefore whether + * it exists at all. + * + * Resolved once, per group, and checked — not merely passed to + * aria-label. A group that refuses one answers undefined (see + * `spawnFromGroup`, and "Exited" for the case that motivates it), + * and an unchecked answer rendered a button with no accessible + * name: a `+` a pointer can press, a click the caller then + * refuses, and nothing for a screen reader to announce it as. + */ + spawn={onSpawnIn === undefined ? undefined : spawnLabel?.(g)} + drag={drag} + panes={panes} + shown={shown} + selected={selected} + onToggleSelect={onToggleSelect} + onToggleGroup={onToggleGroup} + onAction={onAction} + onSpawnIn={onSpawnIn} + peek={peek} + /> + ))} +
    + {/* + The dragged row as a ghost: not the row itself — a full-width row + under a fingertip would hide the headings it is aimed at — but the + one thing that identifies it, in a card the elevation tokens already + know how to draw. No drop animation: the row's real move is the next + list re-grouping, and a ghost gliding back to a place the row is + about to leave would animate a lie. + */} + + {dragging === null ? null : ( +
    +

    + {displayName(dragging)} +

    +
    + )} +
    +
    + ) +} + +/** + * One group: its heading band, and its run of rows while unfolded. + * + * A component rather than a map body so the droppable hook has somewhere + * legal to live — one droppable per group, covering the section whole, + * because the issue a drop answers is "put this session *there*", and there + * is the group as a place: heading and rows alike. The highlight lands on + * the band alone, where the reader is aiming. + */ +function GroupSection({ + g, + open, + boundary, + spawn, + drag, + panes, + shown, + selected, + onToggleSelect, + onToggleGroup, + onAction, + onSpawnIn, + peek, +}: { + g: Group + open: boolean + boundary: number + spawn?: string + drag?: DragToGroup + panes?: ReadonlyMap + shown: ColumnKey[] + selected: ReadonlySet + onToggleSelect(key: string): void + onToggleGroup(groupKey: string): void + onAction(action: RowAction, s: FleetSession): void + onSpawnIn?(group: Group): void + peek?: PeekFn +}) { + /* + * Registered even for a heading that refuses drops, and deliberately: the + * refusal has an affordance — the no-drop cursor, the notice on release — + * and both need the drag layer to know the pointer is over this group at + * all. Only `droppable` decides whether anything lights up. + */ + const { setNodeRef, isOver } = useDroppable({ + id: g.key, + data: { group: g }, + disabled: drag === undefined, + }) + const droppable = drag !== undefined && drag.droppable(g) + return ( +
    + {/* + Two real controls in the band, and they are kept apart on + purpose: folding a group and starting a session in it must never + be the same click. The toggle owns the chevron and the label so + its accessible name is the group's; the tally is read-along + text; and the `+` is ranged right, where a new-thing control + belongs and where a pointer aimed at the fold cannot reach it. + It appears only when the caller can honour it — see + `spawnFromGroup`, which is what decides that a heading like + "Exited" has nothing to offer. + */} +
    +
    + /> + {g.label} + + + {tally(g.sessions)} + + {spawn !== undefined && onSpawnIn !== undefined && ( + + )} +
    + {open && ( +
      + {g.sessions.map((s, at) => ( + // Keyed by the same composite the selection uses: unique + // within a group, even when the session itself repeats + // across tag groups in their own lists. + + {at === boundary && at > 0 && } + + + ))} +
    + )} + ) } diff --git a/web/src/routes/sessions.test.tsx b/web/src/routes/sessions.test.tsx index 2ab5b0c..3e06a4a 100644 --- a/web/src/routes/sessions.test.tsx +++ b/web/src/routes/sessions.test.tsx @@ -139,6 +139,19 @@ function listed(sock: FakeSocket, sessions: SessionInfo[]) { const newSession = () => screen.getByRole('button', { name: 'New session' }) +/** + * The route's own status line. By role alone it stopped being unique: the + * drag layer (dnd-kit's DndContext) portals a live region onto document.body + * that is also role=status. The route's is the one and only `p` — the drag + * layer's is a div — and pinning the tag keeps every assertion about notices + * reading the element the reader actually sees. + */ +function notice(): HTMLElement { + const line = screen.getAllByRole('status').find((el) => el.tagName === 'P') + if (line === undefined) throw new Error('the notice line is not on screen') + return line +} + /** Open one of the display-options selects and take the option reading `label`. */ async function pick(user: ReturnType, of: string, label: string) { const trigger = screen.getByRole('combobox', { name: of }) @@ -388,7 +401,7 @@ describe('SessionsRoute', () => { await user.click(screen.getByRole('menuitem', { name: 'Close' })) expect(sock.ofType('close')).toEqual([{ type: 'close', id: 's1' }]) - expect(screen.getByRole('status').textContent).toContain('Closing alpha') + expect(notice().textContent).toContain('Closing alpha') }) it('announces a refusal that answers an act nobody can correlate', async () => { @@ -404,7 +417,7 @@ describe('SessionsRoute', () => { await user.click(screen.getByRole('menuitem', { name: 'Close' })) act(() => sock.emitControl({ type: 'error', code: 'not_found', msg: 'no such session' })) - expect(screen.getByRole('status').textContent).toBe('That session is gone.') + expect(notice().textContent).toBe('That session is gone.') }) it('announces one from a remote machine too, not only the ridden one', async () => { @@ -415,7 +428,7 @@ describe('SessionsRoute', () => { attic.sockets[0]!.emitControl({ type: 'error', code: 'not_found', msg: 'no such session' }), ) - expect(screen.getByRole('status').textContent).toBe('That session is gone.') + expect(notice().textContent).toBe('That session is gone.') }) it('leaves a correlated refusal to whoever holds its request', async () => { @@ -429,7 +442,7 @@ describe('SessionsRoute', () => { sock.emitControl({ type: 'error', code: 'not_found', msg: 'no such session', reqId: 9 }), ) - expect(screen.getByRole('status').textContent).toBe('') + expect(notice().textContent).toBe('') }) it('says nothing about an error that is not a missing session', async () => { @@ -437,7 +450,7 @@ describe('SessionsRoute', () => { act(() => sock.emitControl({ type: 'error', code: 'lagged', msg: 'too far behind' })) - expect(screen.getByRole('status').textContent).toBe('') + expect(notice().textContent).toBe('') }) }) @@ -466,7 +479,7 @@ describe('SessionsRoute', () => { expect(attic.sockets[0]!.ofType('close')).toEqual([{ type: 'close', id: 's2' }]) // The act consumes the selection: the bar leaves with it. expect(screen.queryByRole('toolbar', { name: 'Bulk actions' })).toBeNull() - expect(screen.getByRole('status').textContent).toContain('Closing 2 sessions') + expect(notice().textContent).toContain('Closing 2 sessions') }) it('pins the whole selection', async () => { @@ -672,7 +685,7 @@ describe('SessionsRoute', () => { act(() => sock.close()) - expect(screen.getByRole('status').textContent).toMatch(/reconnecting/i) + expect(notice().textContent).toMatch(/reconnecting/i) }) it('shows a revoked machine as a final band, with no retry to press', async () => { @@ -717,8 +730,8 @@ describe('SessionsRoute', () => { // The live region announces the fact; the reconnect line would be a // promise nothing is keeping. - expect(screen.getByRole('status').textContent).toMatch(/access was revoked/i) - expect(screen.getByRole('status').textContent).not.toMatch(/reconnecting/i) + expect(notice().textContent).toMatch(/access was revoked/i) + expect(notice().textContent).not.toMatch(/reconnecting/i) // And the band names the ridden machine as its welcome named it. expect(screen.getByText('mesa.local')).toBeTruthy() expect(screen.getByText(/revoked by another device/)).toBeTruthy() @@ -729,11 +742,11 @@ describe('SessionsRoute', () => { // already in the accessibility tree, so one that arrives together with // its first message is a message nobody hears. const { sock } = await mountSessions() - expect(screen.getByRole('status').textContent).toBe('') + expect(notice().textContent).toBe('') act(() => sock.close()) - expect(screen.getByRole('status').textContent).toMatch(/reconnecting/i) + expect(notice().textContent).toMatch(/reconnecting/i) }) }) @@ -1071,7 +1084,7 @@ describe('SessionsRoute', () => { }) await saveAs(user, 'Ops') - expect(screen.getByRole('status').textContent).toContain('Could not save the view') + expect(notice().textContent).toContain('Could not save the view') expect(screen.queryByRole('button', { name: 'Ops' })).toBeNull() }) }) diff --git a/web/src/routes/sessions.tsx b/web/src/routes/sessions.tsx index 071a324..8c7c7c0 100644 --- a/web/src/routes/sessions.tsx +++ b/web/src/routes/sessions.tsx @@ -9,7 +9,7 @@ import { NewSessionDialog } from '@/components/new-session-dialog' import { PageHeader } from '@/components/page-header' import { RenameDialog } from '@/components/rename-dialog' import { SessionSearch } from '@/components/session-search' -import { SessionTable, type RowAction } from '@/components/session-table' +import { SessionTable, type DragToGroup, type RowAction } from '@/components/session-table' import { TagEditor } from '@/components/tag-editor' import { ViewTabs } from '@/components/view-tabs' import { Button } from '@/components/ui/button' @@ -33,6 +33,8 @@ import { applyView, DEFAULT_VIEW, displayName, + dropOnGroup, + groupAcceptsDrop, hiddenExited, spawnFromGroup, type Group, @@ -483,6 +485,42 @@ export function SessionsRoute() { return 'New session' } + /** + * Drag a row, drop it on a heading: give the session that heading's fact. + * + * The meaning of a drop is `dropOnGroup`'s, pure and unit-tested, exactly + * as the heading's `+` defers to `spawnFromGroup`; this is only the glue + * that carries a verdict out. A retag is the same `update` the tag editor + * sends — drag and drop is a shortcut for an existing edit, never a new + * power — followed by an immediate `list`, because the row only moves when + * the next delivery re-groups, and a poll's worth of nothing after a drop + * reads as the drop not working. The notice says what happened either way: + * a successful move in words, since the rows reshuffle under the pointer, + * and a refusal in the verdict's own reason. Ungrouped there is nowhere to + * drop, so the whole layer is switched off rather than offered and refused. + */ + const dragToGroup = useMemo(() => { + if (view.grouping === 'none') return undefined + const grouping = view.grouping + return { + droppable: () => groupAcceptsDrop(grouping), + onDrop: (s, fromKey, group) => { + const verdict = dropOnGroup(grouping, s, fromKey, group.key) + if (verdict.kind === 'retag') { + fleet.update(s.machineId, { id: s.id, tags: verdict.tags }) + fleet.list() + setNotice( + verdict.tags.length === 0 + ? `Removed the tags from ${displayName(s)}.` + : `Moved ${displayName(s)} to ${group.label}.`, + ) + } else if (verdict.kind === 'reject') { + setNotice(verdict.reason) + } + }, + } + }, [view.grouping, fleet]) + /** * How a row asks what it is doing, for the hover preview. * @@ -775,6 +813,7 @@ export function SessionsRoute() { onAction={onAction} onSpawnIn={spawnInGroup} spawnLabel={spawnLabel} + drag={dragToGroup} peek={peek} /> )} diff --git a/web/src/sessions/view.test.ts b/web/src/sessions/view.test.ts index e265a72..2d6bc5a 100644 --- a/web/src/sessions/view.test.ts +++ b/web/src/sessions/view.test.ts @@ -7,7 +7,9 @@ import { COLUMN_LABELS, DEFAULT_VIEW, displayName, + dropOnGroup, filterSessions, + groupAcceptsDrop, GROUPING_LABELS, GROUPINGS, groupSessions, @@ -569,3 +571,107 @@ describe('spawnFromGroup', () => { } }) }) + +describe('groupAcceptsDrop', () => { + it('admits drops onto tag headings alone', () => { + // Tags are the one group-defining fact a person assigns; everything else + // is derived from the session or pinned to a daemon, and a heading that + // highlighted for a drop it must refuse would be an offer made in bad + // faith. + for (const grouping of GROUPINGS) { + expect(groupAcceptsDrop(grouping), grouping).toBe(grouping === 'tag') + } + }) +}) + +describe('dropOnGroup', () => { + it('moves a session between tags: the source tag off, the target on', () => { + // "Put this there" — a session that kept the tag it was dragged out of + // would still sit under the heading the reader just removed it from, + // which reads as a drop that did not work. + const row = s({ tags: ['api'] }) + expect(dropOnGroup('tag', row, 'tag:api', 'tag:ops')).toEqual({ + kind: 'retag', + tags: ['ops'], + }) + }) + + it('leaves the tags the gesture never named alone', () => { + const row = s({ tags: ['api', 'db', 'edge'] }) + expect(dropOnGroup('tag', row, 'tag:api', 'tag:ops')).toEqual({ + kind: 'retag', + tags: ['db', 'edge', 'ops'], + }) + }) + + it('does not double a tag the session already carries', () => { + // Dragged out of `api` onto `ops` while already tagged both: the move is + // still a move — api comes off — and ops must appear once, not twice. + const row = s({ tags: ['api', 'ops'] }) + expect(dropOnGroup('tag', row, 'tag:api', 'tag:ops')).toEqual({ + kind: 'retag', + tags: ['ops'], + }) + }) + + it('tags an untagged session dropped onto a tag', () => { + const row = s({ tags: [] }) + expect(dropOnGroup('tag', row, 'untagged', 'tag:api')).toEqual({ + kind: 'retag', + tags: ['api'], + }) + }) + + it('clears every tag on a drop onto the untagged remainder', () => { + // "No tag" is not a tag to swap in but the absence being pointed at, and + // half-clearing would leave the row under headings the reader just + // dragged it away from. + const row = s({ tags: ['api', 'ops'] }) + expect(dropOnGroup('tag', row, 'tag:api', 'untagged')).toEqual({ kind: 'retag', tags: [] }) + }) + + it('keeps a colon that belongs to the tag rather than to the prefix', () => { + const row = s({ tags: ['a:b'] }) + expect(dropOnGroup('tag', row, 'tag:a:b', 'tag:c:d')).toEqual({ + kind: 'retag', + tags: ['c:d'], + }) + }) + + it('has nothing to say about a drop back onto its own heading', () => { + const row = s({ tags: ['api'] }) + expect(dropOnGroup('tag', row, 'tag:api', 'tag:api')).toEqual({ kind: 'none' }) + expect(dropOnGroup('machine', row, 'machine:m1', 'machine:m1')).toEqual({ kind: 'none' }) + expect(dropOnGroup('tag', s(), 'untagged', 'untagged')).toEqual({ kind: 'none' }) + }) + + it('refuses a machine heading, with the reason said in words', () => { + // A live shell cannot cross daemons; the pointer was allowed to make the + // offer, so the refusal has to be answerable out loud. + const verdict = dropOnGroup('machine', s(), 'machine:m1', 'machine:m2') + expect(verdict.kind).toBe('reject') + if (verdict.kind === 'reject') expect(verdict.reason).toMatch(/machine/) + }) + + it('refuses the derived groupings, state and directory', () => { + for (const [grouping, from, to] of [ + ['state', 'state:running', 'state:exited'], + ['directory', 'dir:/a', 'dir:/b'], + ] as const) { + const verdict = dropOnGroup(grouping, s(), from, to) + expect(verdict.kind, grouping).toBe('reject') + } + }) + + it('shrugs at the ungrouped view, where there is nothing to drop onto', () => { + expect(dropOnGroup('none', s(), 'all', 'all')).toEqual({ kind: 'none' }) + expect(dropOnGroup('none', s(), 'all', 'other')).toEqual({ kind: 'none' }) + }) + + it('leaves the session it was asked about untouched', () => { + const row = s({ tags: ['api', 'ops'] }) + dropOnGroup('tag', row, 'tag:api', 'untagged') + dropOnGroup('tag', row, 'tag:api', 'tag:edge') + expect(row.tags).toEqual(['api', 'ops']) + }) +}) diff --git a/web/src/sessions/view.ts b/web/src/sessions/view.ts index bb7db9e..4ef5232 100644 --- a/web/src/sessions/view.ts +++ b/web/src/sessions/view.ts @@ -410,6 +410,86 @@ function after(key: string, prefix: string): string { return key.startsWith(prefix) ? key.slice(prefix.length) : key } +/** + * What letting a dragged row go over a group's heading should do. + * + * `retag` is the one thing a drop can actually change: a session's tags are + * the only group-defining fact a person assigns, so a drag between tag + * headings is a plain metadata edit — the same one the tag editor performs, + * which is why drag and drop can never become the only path to it. `reject` + * is a drop the screen must answer out loud, in the reason given, because the + * pointer was allowed to make an offer the data cannot honour. `none` is a + * drop with nothing to say — the row let go where it already was. + */ +export type DropVerdict = + | { kind: 'retag'; tags: string[] } + | { kind: 'reject'; reason: string } + | { kind: 'none' } + +/** + * Whether this grouping's headings can take a drop at all. + * + * Only tags: a machine heading names a daemon a live shell cannot cross to, + * and state and directory are read off the session rather than assigned to + * it. The distinction is what the drag layer draws — valid targets highlight, + * the rest answer the pointer with a no-drop cursor — so it is decided here, + * beside the verdicts it must always agree with. + */ +export function groupAcceptsDrop(grouping: Grouping): boolean { + return grouping === 'tag' +} + +/** + * The verdict on one drop: this session, picked up under `fromKey`, let go + * over `toKey`. + * + * A tag drop *moves* rather than merely adds — the tag it was picked up + * under comes off and the tag it landed on goes on — because the gesture is + * "put this there", and a session that stayed under the heading it was + * dragged out of would read as a drop that did not work. The rest of the + * session's tags are none of the gesture's business and survive untouched. + * Landing on the untagged remainder clears every tag, since "no tag" is not + * a tag to swap in but the absence being pointed at. + * + * The refusals name their reason in a full sentence, because the reason is + * the answer to the question the reader just asked with their pointer. + */ +export function dropOnGroup( + grouping: Grouping, + s: FleetSession, + fromKey: string, + toKey: string, +): DropVerdict { + if (fromKey === toKey) return { kind: 'none' } + switch (grouping) { + case 'tag': { + if (toKey === UNTAGGED_KEY) return { kind: 'retag', tags: [] } + const fromTag = fromKey === UNTAGGED_KEY ? null : after(fromKey, 'tag:') + const toTag = after(toKey, 'tag:') + const tags = s.tags.filter((t) => t !== fromTag) + if (!tags.includes(toTag)) tags.push(toTag) + return { kind: 'retag', tags } + } + case 'machine': + return { + kind: 'reject', + reason: 'A session cannot move to another machine. Its shell runs where it started.', + } + case 'state': + return { + kind: 'reject', + reason: 'State cannot be assigned. It reports what the session is doing.', + } + case 'directory': + return { + kind: 'reject', + reason: 'Directory cannot be assigned. It is where the session runs.', + } + case 'none': + return { kind: 'none' } + } +} + /** Gather the rows into their buckets, then put the buckets in reading order. */ function collect(list: FleetSession[], bucketsOf: (s: FleetSession) => Bucket[]): Group[] { const found = new Map() From 97684d840caaccacd2abd753967e1346f11f38c5 Mon Sep 17 00:00:00 2001 From: Karn Date: Tue, 18 Aug 2026 00:04:44 +0530 Subject: [PATCH 2/2] A grip says the rows drag, where dragging can land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drag and drop nobody has tried is drag and drop nobody has: the gesture needed a face. Each row now wears a small grip ahead of its checkbox, quiet until hover exactly as the checkbox and the row menu are, and at full strength for a coarse pointer, which has no hover to learn from. It is an advertisement rather than a handle — the whole row still lifts — so it only renders while some heading on screen would take the drop: a grip under the machine grouping would promise a move that every release refuses. Co-Authored-By: Claude Fable 5 --- web/src/components/session-table.test.tsx | 20 ++++++++++++ web/src/components/session-table.tsx | 38 +++++++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/web/src/components/session-table.test.tsx b/web/src/components/session-table.test.tsx index e8c939c..dfa29fe 100644 --- a/web/src/components/session-table.test.tsx +++ b/web/src/components/session-table.test.tsx @@ -559,6 +559,26 @@ describe('SessionTable', () => { expect(link.getAttribute('href')).toBe('/d/m1/s/a1') }) + it('wears a grip only where a drop could actually land', async () => { + // The grip is an advertisement, not the handle it looks like — the + // gesture works from the whole row. It renders when some heading on + // screen would take the drop, and stays away both from lists that + // cannot drag and from groupings whose every heading refuses, where it + // would promise a move that only ever says no. + const bare = await renderTable() + expect(bare.queryByTitle('Drag to move to another group')).toBeNull() + bare.unmount() + + const refused = await renderTable({ + drag: { droppable: () => false, onDrop: vi.fn() }, + }) + expect(refused.queryByTitle('Drag to move to another group')).toBeNull() + refused.unmount() + + await renderTable({ drag: { droppable: () => true, onDrop: vi.fn() } }) + expect(screen.getByTitle('Drag to move to another group')).toBeTruthy() + }) + it('claims the long press only while dragging is on offer', async () => { // select-none and the callout suppression are what let a finger hold a // row without iOS answering with selection or the link preview — and diff --git a/web/src/components/session-table.tsx b/web/src/components/session-table.tsx index 6679cea..7ab04d3 100644 --- a/web/src/components/session-table.tsx +++ b/web/src/components/session-table.tsx @@ -19,6 +19,7 @@ import { PlusIcon, StarIcon, } from '@heroicons/react/16/solid' +import { GripVerticalIcon } from 'lucide-react' import { SessionPreview } from '@/components/session-preview' import { Badge } from '@/components/ui/badge' @@ -205,6 +206,7 @@ function SessionRow({ s, groupKey, drag, + handle, paneCount, shown, selected, @@ -217,6 +219,8 @@ function SessionRow({ groupKey: string /** The drag layer's contract, when the caller offers one. See DragToGroup. */ drag?: DragToGroup + /** Whether to show the grip — only when a drop could land somewhere. */ + handle: boolean /** Panes folded into this row, when its group has more than one. */ paneCount?: number shown: ColumnKey[] @@ -296,6 +300,30 @@ function SessionRow({ drag !== undefined && 'select-none [-webkit-touch-callout:none]', )} > + {/* + The grip is how the drag says it exists before anyone has tried it — + the gesture works from anywhere on the row, so this is an + advertisement rather than the handle it looks like. Quiet until the + row is hovered, exactly as the checkbox and the ⋯ trigger are, and at + full strength for a coarse pointer, which has no hover and no grab + cursor to learn from. Rendered only when a drop could land somewhere + (see SessionTable): a grip on a row whose every target refuses would + advertise a gesture that only ever says no. z-10 so it takes its own + pointer — the grab cursor is half the signal, and under the link's + overlay it would read as one more place to click. aria-hidden because + it is not operable on its own: the keyboard path is the tag editor, + and a focusable handle no key can lift would be a lie told to exactly + the people who rely on it. + */} + {handle && ( + + )} onToggleSelect(key)} @@ -569,6 +597,12 @@ export function SessionTable({ // The pinned rule only makes sense where there are no headings to do the // separating: the single 'all' group of the ungrouped view. const ungrouped = groups.length === 1 && groups[0]!.key === 'all' + // Whether the rows wear a grip: only while some heading on screen would + // actually take the drop. The gesture itself stays live either way — the + // refusals are part of what it says — but an advertisement is a promise, + // and a grip down a list with nowhere to go promises a move that every + // release would refuse. + const liftable = drag !== undefined && groups.some((g) => drag.droppable(g)) /* * The drag layer's own bookkeeping, all of it about saying things: who the @@ -630,6 +664,7 @@ export function SessionTable({ */ spawn={onSpawnIn === undefined ? undefined : spawnLabel?.(g)} drag={drag} + handle={liftable} panes={panes} shown={shown} selected={selected} @@ -677,6 +712,7 @@ function GroupSection({ boundary, spawn, drag, + handle, panes, shown, selected, @@ -691,6 +727,7 @@ function GroupSection({ boundary: number spawn?: string drag?: DragToGroup + handle: boolean panes?: ReadonlyMap shown: ColumnKey[] selected: ReadonlySet @@ -786,6 +823,7 @@ function GroupSection({ s={s} groupKey={g.key} drag={drag} + handle={handle} paneCount={panes?.get(keyOf(s))} shown={shown} selected={selected}