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..dfa29fe 100644
--- a/web/src/components/session-table.test.tsx
+++ b/web/src/components/session-table.test.tsx
@@ -536,6 +536,65 @@ 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('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
+ // 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..7ab04d3 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,
@@ -6,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'
@@ -26,6 +40,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 +204,9 @@ const TAG_CAP = 3
*/
function SessionRow({
s,
+ groupKey,
+ drag,
+ handle,
paneCount,
shown,
selected,
@@ -172,6 +215,12 @@ 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
+ /** 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[]
@@ -183,6 +232,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 +285,45 @@ function SessionRow({
)
return (
-
+
+ {/*
+ 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)}
@@ -408,6 +513,7 @@ export function SessionTable({
onAction,
onSpawnIn,
spawnLabel,
+ drag,
peek,
}: {
groups: Group[]
@@ -432,9 +538,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
@@ -471,101 +597,244 @@ 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
+ * 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.
- */}
-
- {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}
+ handle={liftable}
+ 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,
+ handle,
+ panes,
+ shown,
+ selected,
+ onToggleSelect,
+ onToggleGroup,
+ onAction,
+ onSpawnIn,
+ peek,
+}: {
+ g: Group
+ open: boolean
+ boundary: number
+ spawn?: string
+ drag?: DragToGroup
+ handle: boolean
+ 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.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()